diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index c1b3a0c7e..77a65be41 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -98,6 +98,7 @@ jobs: # Requires SONAR_TOKEN and SONAR_ORGANIZATION secrets configured in GitHub # If secrets are not configured, this step will skip gracefully - name: SonarCloud Scan + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' env: GITHUB_ACTOR: ${{ github.actor }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index ca23990f5..1d07251c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,11 @@ This project uses Gradle with Kotlin DSL. Java 21 LTS is required. ./gradlew runEditor # Launch editor GUI ./gradlew runExampleGui # Animated GUI simulation (Issue #268, milestone complete 2026-02-04) +# Goal 7 speed-control shortcuts (simulation mode only) +# 1-5 -> 0.5x, 1x, 2x, 5x, 10x +# +/- -> multiply or divide speed by 1.5 +# Space -> pause/resume toggle (Goal 8 integration point) + # Other tasks ./gradlew javadoc # Generate documentation ./gradlew dependencies # Show dependency tree @@ -59,6 +64,26 @@ This project uses Gradle with Kotlin DSL. Java 21 LTS is required. 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". +### Running Simulation with Speed Control + +Use the animated GUI when you need live speed changes: + +```bash +# Built-in animated example with speed controls +./gradlew runExampleGui + +# Equivalent manual JAR launch +java -jar build/libs/interlockSim.jar exampleGui shuntingLoop 300 +``` + +For XML files loaded from the desktop UI, use **Simulation → Start...** and then adjust speed with: + +- the speed slider (`0.1x` to `10.0x`) +- preset buttons/menu items (`0.1x`, `0.5x`, `1x`, `2x`, `5x`, `10x`, `50x`) +- global keyboard shortcuts (`1`-`5`, `+`, `-`, `Space`) + +The status bar shows `Speed: X.Xx` whenever the speed differs from `1.0x`. + ### Directory Structure - `core/` - KMP `:core` subproject (domain model, simulation engine, XML) @@ -170,6 +195,12 @@ For complete navigation services architecture, Koin DI integration patterns, and - AnimatedSim: Real-time animated GUI simulation (Issue #268, milestone complete 2026-02-04) - Physics-accurate rendering with velocity/acceleration visualization - Visual train movement with smooth interpolation + - Goal 7 speed control is built around `SimulationRunner`, which applies wall-clock throttling without changing event semantics + - `SimulationController` owns lifecycle, persists the selected speed, and reapplies it on the next simulation start + - `SimulationControlPanel` provides a `0.1x`-`10.0x` slider plus preset buttons up to `50x` + - `StatusBar.updateSpeedIndicator()` shows the live multiplier whenever speed is not `1.0x` + - `SimulationKeyBindings` installs global shortcuts (`1`-`5`, `+`, `-`, `Space`) while the frame is in simulation mode + - `Space` currently toggles `SimulationRunner.isPaused` directly as Goal 8 pause-feature groundwork - See `docs/ANIMATION_ARCHITECTURE.md` for technical details **XML Configuration:** diff --git a/LONG_TERM_GOALS.md b/LONG_TERM_GOALS.md index 13f89754e..001cea094 100644 --- a/LONG_TERM_GOALS.md +++ b/LONG_TERM_GOALS.md @@ -209,6 +209,7 @@ Users can automatically collect key performance indicators during simulation inc **Category:** I: System Operations **Priority:** Critical **Development Estimate:** 1 month +**Status:** ✅ COMPLETE **User Value:** 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 **Dependencies:** None (quick win) **Implementation Notes:** -- kDisco already supports speed control; needs UI exposure +- 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. +- 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. +- `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. - Quick win - implement early for immediate value --- diff --git a/core-test/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/testutil/MockSimulationContext.kt b/core-test/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/testutil/MockSimulationContext.kt index 9d3487457..8eaf4c39e 100644 --- a/core-test/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/testutil/MockSimulationContext.kt +++ b/core-test/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/testutil/MockSimulationContext.kt @@ -23,7 +23,11 @@ import cz.vutbr.fit.interlockSim.objects.core.Cell import cz.vutbr.fit.interlockSim.objects.core.Cell.Segment import cz.vutbr.fit.interlockSim.objects.core.DynamicPathSeparator import cz.vutbr.fit.interlockSim.objects.core.OrientedPathSeparator +import cz.vutbr.fit.interlockSim.objects.core.ContextChangeEvent +import cz.vutbr.fit.interlockSim.objects.core.ContextPropertyChangeListener import cz.vutbr.fit.interlockSim.objects.core.Track +import cz.vutbr.fit.interlockSim.objects.core.TrackFacility +import cz.vutbr.fit.interlockSim.objects.tracks.DynamicTrack import cz.vutbr.fit.interlockSim.objects.tracks.DynamicTrackBlock import cz.vutbr.fit.interlockSim.sim.InOutWorker import org.koin.mp.KoinPlatformTools @@ -42,6 +46,22 @@ class MockSimulationContext( private val workers: MutableMap = mutableMapOf() private val enabledReports: MutableCollection = mutableListOf() private var stopped: Boolean = false + private var runListeners: List = emptyList() + + /** Number of times [close] has been called. Used by tests to verify scope cleanup. */ + var closeCount: Int = 0 + private set + + /** + * On-demand cache for [DynamicTrack] wrappers. + * + * [DefaultSimulationContext.toDynamic] requires [initializeDynamicMapping] to have + * been called first (normally inside [DefaultSimulationContext.run]). Tests that call + * [Frame.setContext] without actually running the simulation trigger the animation + * system which calls [toDynamic] before [run]. This cache creates wrappers on demand + * so tests do not need to call [run] first. + */ + private val dynamicTrackCache: MutableMap = mutableMapOf() init { // Enable all standard reports by default @@ -68,8 +88,29 @@ class MockSimulationContext( return delegate.getInOuts() } + override fun addPropertyChangeListener(listener: ContextPropertyChangeListener) { + runListeners = runListeners + listener + delegate.addPropertyChangeListener(listener) + } + + override fun removePropertyChangeListener(listener: ContextPropertyChangeListener) { + runListeners = runListeners - listener + delegate.removePropertyChangeListener(listener) + } + override fun run() { stopped = false + // Fire directly from runListeners so callers waiting on addPropertyChangeListener + // (e.g. FrameSimulationLifecycleTest) are notified when simulation starts. + // Cannot rely on delegate.freeze() because ContextTransformer already freezes the + // delegate at creation time, making freeze() a no-op here. + val event = ContextChangeEvent("frozen", false, true) + runListeners.forEach { it.propertyChange(event) } + } + + override fun close() { + closeCount++ + delegate.close() } override fun stop() { @@ -139,6 +180,26 @@ class MockSimulationContext( ): Boolean { return true } + + /** + * Returns a [DynamicTrack] wrapper for [track], creating one on demand if needed. + * + * Delegate's map is only populated after [DefaultSimulationContext.run] calls + * [initializeDynamicMapping]. Tests that call [Frame.setContext] without starting + * the simulation trigger the animation system before [run], so the map is empty. + * This override falls back to an on-demand cache so tests remain independent of + * simulation startup order. + */ + override fun toDynamic(track: TrackFacility): DynamicTrack { + return try { + delegate.toDynamic(track) + } catch (_: IllegalStateException) { + // Expected: delegate map is empty before initializeDynamicMapping() runs (i.e., + // before DefaultSimulationContext.run()). Create a wrapper on demand for test use. + val staticKey = (track as? DynamicTrackBlock)?.staticRef as? TrackFacility ?: track + dynamicTrackCache.getOrPut(staticKey) { DynamicTrack(staticKey) } + } + } } /** diff --git a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/context/DefaultSimulationContext.kt b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/context/DefaultSimulationContext.kt index 0409e20b6..d595efff4 100644 --- a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/context/DefaultSimulationContext.kt +++ b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/context/DefaultSimulationContext.kt @@ -1533,4 +1533,12 @@ open class DefaultSimulationContext( fun setMainProcess(process: LoopProcess) { mainProcess = process } + + /** + * Returns the currently registered main process, or `null` if none has been set + * via [setMainProcess]. Callers may downcast to runtime-control interfaces such + * as [cz.vutbr.fit.interlockSim.sim.SpeedControllable] to retune the live + * simulation (e.g. wall-clock pacing) from the EDT. + */ + fun getMainProcess(): LoopProcess? = mainProcess } diff --git a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoop.kt b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoop.kt index 55a967e52..b1eaba5d1 100644 --- a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoop.kt +++ b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoop.kt @@ -67,12 +67,22 @@ class ShuntingLoop( context: SimulationContext, private val endTime: Long, private val enableRealTimeSync: Boolean = false, - private val speedMultiplier: Double = 1.0, + initialSpeedMultiplier: Double = 1.0, private val pathReservationService: PathReservationService = context.getPathReservationService() ) : Interlocking(context), + SpeedControllable, KoinComponent { + @kotlin.concurrent.Volatile + override var speedMultiplier: Double = initialSpeedMultiplier + set(value) { + require(value > 0.0) { "Speed multiplier must be positive, got: $value" } + field = value + } + init { - require(speedMultiplier > 0.0) { "Speed multiplier must be positive, got: $speedMultiplier" } + require(initialSpeedMultiplier > 0.0) { + "Speed multiplier must be positive, got: $initialSpeedMultiplier" + } } // Inject registry for idempotent path reservation checks @@ -139,7 +149,6 @@ class ShuntingLoop( private val blockTransitionsByTrain: MutableMap = mutableMapOf() private inner class RealTimeSynch : LoopProcess() { - private var presvihnuto: Double = 0.0 private var beginTime: Long = 0 override suspend fun startAction() { @@ -155,15 +164,12 @@ class ShuntingLoop( // Simulation termination is handled by LoopProcess.terminate() setting the flag // which is checked between iterations — platformSleep interrupt does not cause a tight loop. platformSleep(sleepTime) - } else if (sleepTime < 0) { - presvihnuto = sleepTime / 1000.0 } } override suspend fun interLoopSleep() { beginTime = currentTimeMillisKMP() - hold(1 + presvihnuto) - presvihnuto = 0.0 + hold(1.0) } } diff --git a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/SpeedControllable.kt b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/SpeedControllable.kt new file mode 100644 index 000000000..06d0c1be2 --- /dev/null +++ b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/SpeedControllable.kt @@ -0,0 +1,26 @@ +/* Brno University of Technology + * Faculty of Information Technology + * + * BSc Thesis 2006/2007 + * + * Railway Interlocking Simulator + * + * Bedrich Hovorka + */ +package cz.vutbr.fit.interlockSim.sim + +/** + * Implemented by simulation processes whose wall-clock pacing can be retuned at runtime. + * + * Implementations MUST treat the property as thread-safe: reads happen on the kDisco + * simulation thread (e.g. inside a real-time-sync loop), writes happen on the EDT + * (or any thread driving GUI controls / keyboard shortcuts). Use `@kotlin.concurrent.Volatile` + * on the backing field. + * + * Range validation is left to the implementation; the canonical bounds used by the + * desktop GUI live on `SimulationRunner.MIN_SPEED`/`MAX_SPEED`. + */ +interface SpeedControllable { + /** Wall-clock speed multiplier. 1.0 = real-time, 2.0 = twice as fast, 0.5 = half-speed. */ + var speedMultiplier: Double +} diff --git a/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoopTest.kt b/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoopTest.kt index 33f23d9b5..ec57e7c0a 100644 --- a/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoopTest.kt +++ b/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoopTest.kt @@ -310,7 +310,7 @@ class ShuntingLoopTest : KoinTestBase() { validContext, 60L, enableRealTimeSync = true, - speedMultiplier = 2.0 + initialSpeedMultiplier = 2.0 ) assertThat(shuntingLoop).isNotNull() } @@ -335,7 +335,7 @@ class ShuntingLoopTest : KoinTestBase() { validContext, 60L, enableRealTimeSync = true, - speedMultiplier = 0.5 + initialSpeedMultiplier = 0.5 ) assertThat(shuntingLoop).isNotNull() } @@ -348,7 +348,7 @@ class ShuntingLoopTest : KoinTestBase() { validContext, 60L, enableRealTimeSync = true, - speedMultiplier = 1.0 + initialSpeedMultiplier = 1.0 ) assertThat(shuntingLoop).isNotNull() } @@ -361,7 +361,7 @@ class ShuntingLoopTest : KoinTestBase() { validContext, 60L, enableRealTimeSync = true, - speedMultiplier = 2.0 + initialSpeedMultiplier = 2.0 ) assertThat(shuntingLoop).isNotNull() } @@ -374,7 +374,7 @@ class ShuntingLoopTest : KoinTestBase() { validContext, 60L, enableRealTimeSync = true, - speedMultiplier = 5.0 + initialSpeedMultiplier = 5.0 ) assertThat(shuntingLoop).isNotNull() } @@ -394,7 +394,7 @@ class ShuntingLoopTest : KoinTestBase() { validContext, 60L, enableRealTimeSync = true, - speedMultiplier = 0.1 + initialSpeedMultiplier = 0.1 ) assertThat(shuntingLoop).isNotNull() } @@ -407,7 +407,7 @@ class ShuntingLoopTest : KoinTestBase() { validContext, 60L, enableRealTimeSync = true, - speedMultiplier = 10.0 + initialSpeedMultiplier = 10.0 ) assertThat(shuntingLoop).isNotNull() } @@ -420,7 +420,7 @@ class ShuntingLoopTest : KoinTestBase() { validContext, 60L, enableRealTimeSync = true, - speedMultiplier = 0.0 + initialSpeedMultiplier = 0.0 ) }.withMessage("Speed multiplier must be positive") .isFailure() @@ -435,12 +435,54 @@ class ShuntingLoopTest : KoinTestBase() { validContext, 60L, enableRealTimeSync = true, - speedMultiplier = -1.0 + initialSpeedMultiplier = -1.0 ) }.withMessage("Speed multiplier must be positive") .isFailure() .isInstanceOf(IllegalArgumentException::class) } + + @Test + @DisplayName("speedMultiplier is mutable post-construction (RealTimeSynch reads live value)") + fun speedMultiplier_isMutableAtRuntime() { + val shuntingLoop = ShuntingLoop(validContext, 60L, enableRealTimeSync = true) + + // Default initial value + assertThat(shuntingLoop.speedMultiplier).isEqualTo(1.0) + + // Mutating from the EDT side (or any thread) must stick — RealTimeSynch.iteration() + // reads from this @Volatile field on the simulation thread, so the value seen by + // the wall-clock pacing must match the latest write. + shuntingLoop.speedMultiplier = 2.5 + assertThat(shuntingLoop.speedMultiplier).isEqualTo(2.5) + + shuntingLoop.speedMultiplier = 0.1 + assertThat(shuntingLoop.speedMultiplier).isEqualTo(0.1) + } + + @Test + @DisplayName("speedMultiplier setter rejects zero") + fun speedMultiplier_setter_zero_throwsException() { + val shuntingLoop = ShuntingLoop(validContext, 60L, enableRealTimeSync = true) + + assertThatBlock { + shuntingLoop.speedMultiplier = 0.0 + }.withMessage("Speed multiplier must be positive") + .isFailure() + .isInstanceOf(IllegalArgumentException::class) + } + + @Test + @DisplayName("speedMultiplier setter rejects negative") + fun speedMultiplier_setter_negative_throwsException() { + val shuntingLoop = ShuntingLoop(validContext, 60L, enableRealTimeSync = true) + + assertThatBlock { + shuntingLoop.speedMultiplier = -1.0 + }.withMessage("Speed multiplier must be positive") + .isFailure() + .isInstanceOf(IllegalArgumentException::class) + } } @Nested diff --git a/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/TrainPathInteractionTest.kt b/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/TrainPathInteractionTest.kt index 89c0a1745..fb74a5e03 100644 --- a/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/TrainPathInteractionTest.kt +++ b/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/TrainPathInteractionTest.kt @@ -12,6 +12,7 @@ package cz.vutbr.fit.interlockSim.sim import assertk.assertThat import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull import cz.vutbr.fit.interlockSim.objects.cells.DynamicInOut import cz.vutbr.fit.interlockSim.testutil.KoinTestBase import cz.vutbr.fit.interlockSim.testutil.MockSimulationContext @@ -145,11 +146,8 @@ class TrainPathInteractionTest : KoinTestBase() { val timetable = Timetable(mockInOut, mockOutOut, Time(0.0), Time(0.0), 50.0) val train = Train(mockContext, timetable) - // Act: Train exists and can handle blocked paths - val trainConstructed = train != null - // Assert: Train can be constructed even with blocked path scenarios - assertThat(trainConstructed).isEqualTo(true) + assertThat(train).isNotNull() } @Test @@ -165,11 +163,8 @@ class TrainPathInteractionTest : KoinTestBase() { val timetable = Timetable(mockInOut, mockOutOut, Time(0.0), Time(0.0), 50.0) val train = Train(mockContext, timetable) - // Act: Train can respond to path availability changes - val trainReady = train != null - // Assert: Train instance valid for path resumption scenarios - assertThat(trainReady).isEqualTo(true) + assertThat(train).isNotNull() } } diff --git a/desktop-ui/build.gradle.kts b/desktop-ui/build.gradle.kts index 18fc0dd80..736cd55b7 100644 --- a/desktop-ui/build.gradle.kts +++ b/desktop-ui/build.gradle.kts @@ -306,12 +306,19 @@ val runExample by tasks.registering(JavaExec::class) { val runExampleGui by tasks.registering(JavaExec::class) { group = "application" - description = "Run animated GUI example (use -PexampleName and -PendTime)" + description = "Run animated GUI example or XML simulation (use -PexampleName=name -PendTime=seconds, OR -PxmlFile=path/to/file.xml)" classpath = sourceSets.main.get().runtimeClasspath mainClass.set(application.mainClass.get()) - val exampleName = project.findProperty("exampleName") as String? ?: "shuntingLoop" - val endTime = project.findProperty("endTime") as String? ?: "60" - args = listOf("exampleGui", exampleName, endTime) + doFirst { + val xmlFile = project.findProperty("xmlFile") as String? + if (xmlFile != null) { + args = listOf("simgui", xmlFile) + } else { + val exampleName = project.findProperty("exampleName") as String? ?: "shuntingLoop" + val endTime = project.findProperty("endTime") as String? ?: "60" + args = listOf("exampleGui", exampleName, endTime) + } + } } val runSimFromXml by tasks.registering(JavaExec::class) { diff --git a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/ExampleRegistry.kt b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/ExampleRegistry.kt index a71b78d37..aebd00f7a 100644 --- a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/ExampleRegistry.kt +++ b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/ExampleRegistry.kt @@ -135,7 +135,7 @@ class ExampleRegistry { // Initialize dynamic wrapper map by calling getInOuts() context.getInOuts() // Enable real-time synchronization for GUI mode with 1x speed multiplier - context.setMainProcess(ShuntingLoop(context, time, enableRealTimeSync = true, speedMultiplier = 1.0)) + context.setMainProcess(ShuntingLoop(context, time, enableRealTimeSync = true, initialSpeedMultiplier = 1.0)) context } } diff --git a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/Main.kt b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/Main.kt index 60f13b7a4..a8d70a62f 100644 --- a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/Main.kt +++ b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/Main.kt @@ -54,7 +54,7 @@ class Main { frame.setVisible(true) } } catch (e: ContextCreationException) { - logger.error(e) { "Context creation failed" } + logger.error(e) { MSG_CONTEXT_CREATION_FAILED } } } @@ -100,7 +100,7 @@ class Main { } } // rawContext closed } catch (e: ContextCreationException) { - logger.error(e) { "Context creation failed" } + logger.error(e) { MSG_CONTEXT_CREATION_FAILED } } catch (e: EmptyContextException) { logger.error(e) { "User hasn't specified valid file" } } catch (e: SimulationException) { @@ -143,21 +143,68 @@ class Main { } } + /** + * Run a simulation from an XML file with the animated GUI (Issue #189). + * + * Loads the railway network from [args][1], converts it to a [SimulationContext], + * then delegates to [showContextInGui] — the same entry point used by [runExampleGui]. + * This ensures both XML-based and example-based animated simulations share identical + * GUI lifecycle handling (SimulationController, Stop button, EventTimelinePanel). + * + * **Command Usage:** + * ``` + * java -jar interlockSim.jar simgui [xmlFile] + * ./gradlew runExampleGui -PxmlFile=vyhybna.xml + * ``` + * + * @param args Command line arguments (expects optional XML file path as args[1]) + */ + fun loadSimWithGui(args: Array) { + try { + val rawContext = createContext(args) + val context: SimulationContext = + when (rawContext) { + // Defensive: createContext() currently always returns EditingContext via + // JvmEditingContextFactory, but if a future factory returns SimulationContext + // directly (mirroring the defensiveness in loadSim()), handle it gracefully. + is SimulationContext -> rawContext + is EditingContext -> rawContext.use { editCtx -> + simulationContextFactory.createContext(editCtx) + } + else -> { + rawContext.close() + throw ContextCreationException( + "Unexpected context type: ${rawContext::class.java.name}" + ) + } + } + + context.addReportTypes(*ReportType.values()) + + val sourceFile = if (args.size > 1) File(args[1]).canonicalFile else null + showContextInGui(context, sourceFile) + } catch (e: ContextCreationException) { + logger.error(e) { MSG_CONTEXT_CREATION_FAILED } + } catch (e: EmptyContextException) { + logger.error(e) { "Simulation with GUI could not be started - empty context" } + } catch (e: Exception) { + logger.error(e) { "Simulation with GUI initialization failed" } + } + } + /** * Run a GUI-based animated example (Issue #206). * * This method creates a simulation example and displays it in the animated Frame * with AnimationController, EventTimelinePanel, and ControlPanel. - * - * **Threading Model:** - * - Main thread: Creates Frame and SimulationContext on EDT via SwingUtilities.invokeLater - * - Simulation thread: kDisco simulation runs on background thread - * - EDT: GUI updates occur on EDT, marshaled by AnimationController + * Delegates to [showContextInGui] — the same entry point used by [loadSimWithGui] — + * so both paths share identical GUI lifecycle handling via [SimulationController]. * * **Command Usage:** * ``` * java -jar interlockSim.jar exampleGui * ./gradlew runExampleGui -PexampleName=shuntingLoop -PendTime=60 + * ./gradlew runExampleGui -PxmlFile=vyhybna.xml * ``` * * @param args Command line arguments (expects example name as args[1], endTime as args[2]) @@ -184,24 +231,10 @@ class Main { val simulationContextFactory = getKoin().get() val context = exampleFactory(simulationContextFactory, args) - // Add all report types for event timeline visibility context.addReportTypes(*ReportType.values()) - // Launch GUI on EDT - javax.swing.SwingUtilities.invokeLater { - frame.setContext(context) - frame.isVisible = true - - // Run simulation on background thread (kDisco simulation thread) - Thread { - try { - context.run() - logger.info { "GUI example simulation completed: $name" } - } catch (e: SimulationException) { - logger.error(e) { "GUI example simulation failed: $name" } - } - }.start() - } + // Reuse the same GUI launch path as loadSimWithGui (no sourceFile for examples) + showContextInGui(context) } catch (e: ContextCreationException) { logger.error(e) { "GUI example context creation failed" } } catch (e: EmptyContextException) { @@ -210,11 +243,55 @@ class Main { logger.error(e) { "GUI example initialization failed" } } } + + /** + * Common GUI launch path shared by [loadSimWithGui] and [runExampleGui]. + * + * Schedules on EDT: sets [context] on [Frame], optionally sets the window title from + * [sourceFile], shows the frame, and starts the simulation via [Frame.startSimulation] + * (which uses [SimulationController] — giving both paths a Stop button and lifecycle + * management without a raw background thread). + * + * The [SimulationContext] is intentionally not closed here; its lifetime is bound to + * the GUI window — [Frame.exitWithoutSaving] calls `System.exit(0)` on window close. + * + * @param context Simulation context to display; must already have report types added. + * @param sourceFile Optional XML file to show in the window title (null for built-in examples). + */ + private fun showContextInGui(context: SimulationContext, sourceFile: File? = null) { + javax.swing.SwingUtilities.invokeLater { + frame.setContext(context) + // Do not update modificationTracker/currentFile in simulation mode: + // File->Save uses that state to enter editing-only save flow. + sourceFile?.let { frame.title = it.name } + frame.isVisible = true + frame.startSimulation() + } + } } // Application metadata constants moved to AppMetadata.kt // PROGRAM_NAME, PROGRAM_VERSION, and PROGRAM_FULL_NAME are now defined in AppMetadata.kt +private const val MSG_CONTEXT_CREATION_FAILED = "Context creation failed" + +/** + * Parses the command mode from the argument list. + * + * Returns the first argument if it is a recognized mode string, or `null` if the + * argument list is empty or the first argument is unrecognized. This pure function + * can be tested without triggering any DI, context creation, or GUI code. + * + * @param args Command line arguments + * @return One of "sim", "simgui", "edit", "example", "exampleGui", or `null` + */ +internal fun parseMode(args: Array): String? { + if (args.isEmpty()) return null + return args[0].takeIf { it in VALID_MODES } +} + +private val VALID_MODES = setOf("sim", "simgui", "edit", "example", "exampleGui") + /** * @param args */ @@ -241,15 +318,17 @@ fun main(args: Array) { ) val main = getKoin().get
() - when { - args.isNotEmpty() && args[0] == "sim" -> main.loadSim(args) - args.isNotEmpty() && args[0] == "example" -> main.runExample(args) - args.isNotEmpty() && args[0] == "exampleGui" -> main.runExampleGui(args) - args.isNotEmpty() && args[0] == "edit" -> main.loadGui(args) + when (parseMode(args)) { + "sim" -> main.loadSim(args) + "simgui" -> main.loadSimWithGui(args) + "example" -> main.runExample(args) + "exampleGui" -> main.runExampleGui(args) + "edit" -> main.loadGui(args) else -> logger.error { - "usage: cz.vutbr.fit.interlockSim.Main (sim|edit|example|exampleGui) [file]\n" + + "usage: cz.vutbr.fit.interlockSim.Main (sim|simgui|edit|example|exampleGui) [file]\n" + "\tsim [file] - Run simulation from XML file\n" + + "\tsimgui [file] - Run simulation from XML file with animated GUI\n" + "\tedit [file] - Launch graphical editor\n" + "\texample - Run console-based example\n" + "\texampleGui - Run GUI-based animated example" diff --git a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/Frame.kt b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/Frame.kt index e1d8f62ce..688ae72a4 100644 --- a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/Frame.kt +++ b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/Frame.kt @@ -15,6 +15,7 @@ import cz.vutbr.fit.interlockSim.context.EditingContext import cz.vutbr.fit.interlockSim.context.SimulationContext import cz.vutbr.fit.interlockSim.gui.animation.ControlPanel import cz.vutbr.fit.interlockSim.gui.animation.EventTimelinePanel +import io.github.oshai.kotlinlogging.KotlinLogging import java.awt.BorderLayout import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent @@ -25,6 +26,7 @@ import javax.swing.JFrame import javax.swing.JOptionPane import javax.swing.JPanel import javax.swing.JScrollPane +import javax.swing.SwingUtilities import javax.swing.Timer /** @@ -32,7 +34,7 @@ import javax.swing.Timer * * Provides dynamic layout that adapts based on context type: * - **Editing Mode** ([EditingContext]): StatusBar visible, ControlPanel hidden - * - **Simulation Mode** ([SimulationContext]): ControlPanel and EventTimelinePanel visible, StatusBar hidden + * - **Simulation Mode** ([SimulationContext]): ControlPanel and EventTimelinePanel visible, StatusBar remains visible (speed indicator shown) * * ## Layout Structure * @@ -65,6 +67,8 @@ import javax.swing.Timer * ├─────────────────────────────────┤ * │ EventTimelinePanel (NEW) │ * │ [Filters] [Event log...] │ + * ├─────────────────────────────────┤ + * │ StatusBar (speed indicator) │ * └─────────────────────────────────┘ * ``` * @@ -74,7 +78,7 @@ import javax.swing.Timer * 1. Creates [EventTimelinePanel] (lazy, reused across simulations) * 2. Wires EventTimelinePanel to [RailwayNetGridCanvas] → [cz.vutbr.fit.interlockSim.gui.animation.AnimationController] * 3. Starts 10 Hz timer for [ControlPanel] time updates - * 4. Shows ControlPanel and EventTimelinePanel, hides StatusBar + * 4. Shows ControlPanel and EventTimelinePanel (above StatusBar); StatusBar remains visible * * When an [EditingContext] is set, the frame: * 1. Hides ControlPanel and EventTimelinePanel @@ -88,6 +92,8 @@ import javax.swing.Timer * * @since 2006-2007 * @see setContext + * @see startSimulation + * @see stopSimulation * @see switchToEditingMode * @see switchToSimulationMode */ @@ -98,9 +104,45 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { // Animation UI components (Issue #205) private val controlPanel: ControlPanel = ControlPanel() + internal val simulationControlPanel: SimulationControlPanel = SimulationControlPanel() private var eventTimelinePanel: cz.vutbr.fit.interlockSim.gui.animation.EventTimelinePanel? = null private var animationUpdateTimer: Timer? = null + // South panel: always at BorderLayout.SOUTH; holds StatusBar and optionally EventTimelinePanel + private val southPanel: JPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + } + + // Simulation lifecycle delegated to SimulationController for testability (Issue #189) + internal val simulationController: SimulationController = + SimulationController( + onStateChanged = { state -> + runOnEdt { + when (state) { + SimulationController.SimulationStatus.RUNNING -> { + toolBar.showSimulationControls() + controlPanel.updateStatus(ControlPanel.SimulationStatus.RUNNING) + controlPanel.setStopEnabled(true) + } + + SimulationController.SimulationStatus.STOPPED -> { + toolBar.hideSimulationControls() + simulationControlPanel.runner = null + controlPanel.setStopEnabled(false) + controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED) + } + } + } + }, + onSpeedChanged = { speed -> + runOnEdt { statusBar.updateSpeedIndicator(speed) } + } + ) + private var currentSimulationContext: SimulationContext? = null + + // Global keyboard shortcuts for simulation speed control (Phase 3.1, Issue #193) + private val simulationKeyBindings: SimulationKeyBindings = SimulationKeyBindings(simulationController) + /** * Tracks modification state for unsaved changes warning. */ @@ -122,10 +164,18 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { northContainer.add(toolBar) controlPanel.isVisible = false // Initially hidden (shown only in simulation mode) northContainer.add(controlPanel) + simulationControlPanel.isVisible = false // Initially hidden (shown only in simulation mode) + northContainer.add(simulationControlPanel) contentPane.add(northContainer, BorderLayout.NORTH) + // Route speed changes from SimulationControlPanel through SimulationController so + // desiredSpeed stays in sync and is applied to the next simulation start. + simulationControlPanel.onSpeedChanged = { speed -> simulationController.setSpeed(speed) } + + // South panel contains StatusBar (edit mode) and EventTimelinePanel (simulation mode) statusBar.registerProducer(railwayNetGridCanvas) - contentPane.add(statusBar, BorderLayout.SOUTH) + southPanel.add(statusBar) + contentPane.add(southPanel, BorderLayout.SOUTH) // Add component listener to refresh canvas when frame is resized addComponentListener( @@ -150,10 +200,12 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { /** * Switch UI layout to simulation mode (Issue #205). * - * - Hides StatusBar - * - Shows EventTimelinePanel (if created) + * - StatusBar remains visible (its speed indicator [StatusBar.updateSpeedIndicator] shows + * non-default speeds; [StatusBar.statusLabel] continues to display simulation events) + * - Adds EventTimelinePanel to south panel (if created) * - Shows ControlPanel * - Disables editing ToolBar + * - Installs global keyboard shortcuts for speed control (Phase 3.1, Issue #193) * * **Must be called from EDT.** */ @@ -162,20 +214,26 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { "switchToSimulationMode must be called from EDT" } - // Hide StatusBar, show EventTimelinePanel - statusBar.isVisible = false - contentPane.remove(statusBar) - eventTimelinePanel?.let { - contentPane.add(it, BorderLayout.SOUTH) + // Add EventTimelinePanel before StatusBar (index 0 = top of south panel, above StatusBar) + eventTimelinePanel?.let { panel -> + if (panel.parent == null) { + southPanel.add(panel, TIMELINE_PANEL_SOUTH_INDEX) + } } - // Show ControlPanel + // Show ControlPanel and SimulationControlPanel controlPanel.isVisible = true - controlPanel.updateStatus("Running") + controlPanel.updateStatus(ControlPanel.SimulationStatus.READY) + simulationControlPanel.isVisible = true // Disable editing toolbar in simulation mode toolBar.setToolsEnabled(false) + // Install keyboard shortcuts for simulation control (Phase 3.1, Issue #193) + simulationKeyBindings.install(rootPane) + + southPanel.revalidate() + southPanel.repaint() contentPane.revalidate() contentPane.repaint() } @@ -183,10 +241,10 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { /** * Switch UI layout to editing mode (Issue #205). * - * - Shows StatusBar - * - Hides EventTimelinePanel + * - Removes EventTimelinePanel from south panel (StatusBar remains visible throughout) * - Hides ControlPanel * - Enables editing ToolBar + * - Uninstalls global keyboard shortcuts (Phase 3.1, Issue #193) * * **Must be called from EDT.** */ @@ -195,19 +253,23 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { "switchToEditingMode must be called from EDT" } - // Show StatusBar, hide EventTimelinePanel - eventTimelinePanel?.let { - contentPane.remove(it) + // Remove EventTimelinePanel from south panel (StatusBar stays visible always) + eventTimelinePanel?.let { panel -> + southPanel.remove(panel) } - contentPane.add(statusBar, BorderLayout.SOUTH) - statusBar.isVisible = true - // Hide ControlPanel + // Hide ControlPanel and SimulationControlPanel controlPanel.isVisible = false + simulationControlPanel.isVisible = false // Enable editing toolbar in editing mode toolBar.setToolsEnabled(true) + // Uninstall keyboard shortcuts (Phase 3.1, Issue #193) + simulationKeyBindings.uninstall(rootPane) + + southPanel.revalidate() + southPanel.repaint() contentPane.revalidate() contentPane.repaint() } @@ -227,10 +289,18 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { * **Must be called from EDT.** */ fun setContext(context: Context<*, *>) { + require(javax.swing.SwingUtilities.isEventDispatchThread()) { + "setContext must be called from EDT" + } + stopSimulation() // Stop any running simulation before switching context stopAnimationUpdates() // Cleanup existing timer + val previousSimulationContext = currentSimulationContext + when (context) { is SimulationContext -> { + currentSimulationContext = context + // Lazy-create event timeline panel (reused across simulations) if (eventTimelinePanel == null) { eventTimelinePanel = EventTimelinePanel() @@ -240,13 +310,21 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { railwayNetGridCanvas.setEventTimelinePanel(eventTimelinePanel) railwayNetGridCanvas.setContext(context) startAnimationUpdates() + + // Wire stop button to stopSimulation() + controlPanel.onStop = { stopSimulation() } + controlPanel.setStopEnabled(false) // enabled only after startSimulation() } is EditingContext -> { + currentSimulationContext = null + controlPanel.onStop = null switchToEditingMode() railwayNetGridCanvas.setContext(context) context.addPropertyChangeListener(modificationTracker) } else -> { + currentSimulationContext = null + controlPanel.onStop = null // Unknown context type - default to simulation mode (read-only) switchToSimulationMode() railwayNetGridCanvas.setContext(context) @@ -254,6 +332,12 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { } context.addPropertyChangeListener(statusBar) + + // Only close the previous context when it is a different instance. + // Closing the same context that was just set would invalidate the Koin scope we just wired. + if (previousSimulationContext !== null && previousSimulationContext !== context) { + previousSimulationContext.close() + } } /** @@ -298,6 +382,56 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { animationUpdateTimer = null } + /** + * Launch the simulation on a background thread via [SimulationController] (Issue #189). + * + * Delegates to [SimulationController.start]. Idempotent: if a simulation is already + * running this call is a no-op. + * + * **Must be called from EDT.** + */ + fun startSimulation() { + require(javax.swing.SwingUtilities.isEventDispatchThread()) { + "startSimulation must be called from EDT" + } + + val context = currentSimulationContext ?: run { + logger.warn { "startSimulation called without a SimulationContext — ignoring" } + return + } + + try { + simulationController.start(context) + simulationControlPanel.runner = simulationController.runner?.takeIf { it.isRunning() } + } catch (e: Exception) { + logger.error(e) { "Failed to start simulation" } + } + } + + /** + * Request immediate simulation shutdown (Issue #189). + * + * Delegates to [SimulationController.stop]. Safe to call when no simulation is + * running (no-op in that case). + * + * **Must be called from EDT.** + */ + fun stopSimulation() { + require(javax.swing.SwingUtilities.isEventDispatchThread()) { + "stopSimulation must be called from EDT" + } + simulationController.stop() + // Detach SimulationControlPanel from runner when simulation stops + simulationControlPanel.runner = null + } + + companion object { + private val logger = KotlinLogging.logger {} + + /** Index at which EventTimelinePanel is inserted in [southPanel] (above StatusBar). */ + private const val TIMELINE_PANEL_SOUTH_INDEX = 0 + } + /** * Updates the window title to reflect current file and dirty state. */ @@ -313,6 +447,20 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { } } + /** + * Execute [action] on EDT. + * + * Runs immediately if already on EDT; otherwise schedules asynchronously via + * [javax.swing.SwingUtilities.invokeLater] to avoid blocking monitor/background threads. + */ + private fun runOnEdt(action: () -> Unit) { + if (SwingUtilities.isEventDispatchThread()) { + action() + } else { + SwingUtilities.invokeLater(action) + } + } + /** * Handles window closing event. * Shows confirmation dialog if there are unsaved changes. @@ -380,6 +528,8 @@ class Frame : JFrame(PROGRAM_FULL_NAME) { * Exits the application without saving. */ private fun exitWithoutSaving() { + stopSimulation() // Stop any running simulation before exit + currentSimulationContext?.close() // Release simulation resources before JVM exit stopAnimationUpdates() // Stop Frame's 10 Hz timer railwayNetGridCanvas.cleanupAnimation() // Stop AnimationController - CRITICAL for GC dispose() diff --git a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/MenuBar.kt b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/MenuBar.kt index f5fd5a42e..9161b1553 100644 --- a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/MenuBar.kt +++ b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/MenuBar.kt @@ -12,15 +12,22 @@ package cz.vutbr.fit.interlockSim.gui import cz.vutbr.fit.interlockSim.context.EditingContext import cz.vutbr.fit.interlockSim.context.JvmEditingContextFactory +import cz.vutbr.fit.interlockSim.context.SimulationContext +import cz.vutbr.fit.interlockSim.context.SimulationContextFactory import cz.vutbr.fit.interlockSim.xml.XMLContextFactory +import io.github.oshai.kotlinlogging.KotlinLogging import org.koin.mp.KoinPlatform.getKoin +import java.awt.Cursor import java.awt.event.ActionEvent import java.io.File +import java.util.concurrent.ExecutionException import javax.swing.AbstractAction import javax.swing.JFileChooser import javax.swing.JMenu import javax.swing.JMenuBar +import javax.swing.JMenuItem import javax.swing.JOptionPane +import javax.swing.SwingWorker /** * Application menu bar with File and Help menus @@ -30,6 +37,8 @@ class MenuBar : JMenuBar() { private val saveAsAction = SaveAsAction() companion object { + private val logger = KotlinLogging.logger {} + /** * Pure validation: returns true if [context] has enough InOut elements to be saved. * Does not show any dialog — callers handle error presentation. @@ -265,6 +274,84 @@ class MenuBar : JMenuBar() { } } + /** + * Shows a file chooser, loads the selected XML as a [SimulationContext], sets it on the + * [Frame] and immediately starts the simulation. + * + * **Resource management:** The intermediate [EditingContext] created by + * [JvmEditingContextFactory.createContext] is wrapped in `use {}` to ensure its Koin + * scope is closed after the [SimulationContext] transformation, preventing a resource + * leak of the temporary editing context. + * + * **Report types:** All report types are enabled on the [SimulationContext] before + * passing it to [Frame.setContext] so that [AnimationController] and + * [cz.vutbr.fit.interlockSim.gui.animation.EventTimelinePanel] receive property-change + * events and the animation is not visually frozen. + * + * **Modification tracker:** The tracker is cleared before switching to simulation mode + * so that the "unsaved changes" path in [Frame.handleWindowClosing] does not attempt to + * save a [SimulationContext] through the editor's save logic. + */ + private inner class StartSimulationAction : AbstractAction("Start...") { + override fun actionPerformed(e: ActionEvent) { + val fileChooser = JFileChooser(System.getProperty("user.dir")) + fileChooser.dialogTitle = "Start Simulation" + + val returnValue = fileChooser.showOpenDialog(this@MenuBar) + if (returnValue != JFileChooser.APPROVE_OPTION) return + + val selectedFile: File = fileChooser.selectedFile + val savedCursor = this@MenuBar.cursor + this@MenuBar.cursor = Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR) + + object : SwingWorker() { + override fun doInBackground(): SimulationContext = loadSimulationContext(selectedFile) + + override fun done() { + this@MenuBar.cursor = savedCursor + val simContext = try { + get() + } catch (ex: ExecutionException) { + logger.error(ex.cause ?: ex) { "Failed to load simulation context from $selectedFile" } + JOptionPane.showMessageDialog( + this@MenuBar, + "Failed to start simulation: ${(ex.cause ?: ex).message}\n\n" + + "Ensure the file is a valid railway network XML.", + "Cannot Start Simulation", + JOptionPane.ERROR_MESSAGE + ) + return + } + + val frame = getKoin().get() + frame.modificationTracker.markClean() + frame.modificationTracker.setCurrentFile(null) + frame.setContext(simContext) + frame.startSimulation() + } + }.execute() + } + } + + /** Terminates the currently running simulation via [Frame.stopSimulation]. */ + private inner class StopSimulationAction : AbstractAction("Stop") { + override fun actionPerformed(e: ActionEvent) { + val frame = getKoin().get() + frame.stopSimulation() + } + } + + /** Sets the simulation speed multiplier via [SimulationController.setSpeed]. */ + private inner class SetSpeedAction( + private val label: String, + private val multiplier: Double, + ) : AbstractAction(label) { + override fun actionPerformed(e: ActionEvent) { + val frame = getKoin().get() + frame.simulationController.setSpeed(multiplier) + } + } + private inner class InfoAction( private val infoName: String, private val text: String @@ -274,8 +361,25 @@ class MenuBar : JMenuBar() { } } + /** + * Parses [file] as a railway XML, transforms it to a [SimulationContext], and enables + * all report types. Must be called off the Event Dispatch Thread. + * + * @throws Exception if the file is unreadable or the XML is invalid. + */ + internal fun loadSimulationContext(file: File): SimulationContext { + val editingContextFactory = getKoin().get() + val simulationContextFactory = getKoin().get() + return editingContextFactory.createContext(file).use { editCtx -> + simulationContextFactory.createContext(editCtx as EditingContext) + }.also { simCtx -> + simCtx.addReportTypes(*SimulationContext.ReportType.values()) + } + } + init { add(fileMenu()) + add(simulationMenu()) add(helpMenu()) } @@ -289,6 +393,39 @@ class MenuBar : JMenuBar() { return menu } + /** + * Builds the "Simulation" menu with Start/Stop actions and a Speed submenu. + * + * Speed presets (0.1x, 0.5x, 1x, 2x, 5x, 10x, 50x) are available via menu items. + * Global keyboard shortcuts (keys 1–5, +/-, Space) are handled by [SimulationKeyBindings] + * during simulation mode (Phase 3.1, Issue #193). + */ + private fun simulationMenu(): JMenu { + val menu = JMenu("Simulation") + menu.add(StartSimulationAction()) + menu.add(StopSimulationAction()) + menu.addSeparator() + + val speedMenu = JMenu("Speed") + val speedPresets = + listOf( + Pair("0.1x", 0.1), + Pair("0.5x", 0.5), + Pair("1x", 1.0), + Pair("2x", 2.0), + Pair("5x", 5.0), + Pair("10x", 10.0), + Pair("50x", 50.0), + ) + for ((label, multiplier) in speedPresets) { + val item = JMenuItem(SetSpeedAction(label, multiplier)) + speedMenu.add(item) + } + menu.add(speedMenu) + + return menu + } + private fun helpMenu(): JMenu { val menu = JMenu("Help") menu.add( @@ -300,7 +437,19 @@ class MenuBar : JMenuBar() { "
Editing:
" + "- Left mouse: Insert nodes and join them
" + "- Middle mouse: Delete nodes
" + - "- Right mouse: Popup menu" + "- Right mouse: Popup menu
" + + "
Simulation:
" + + "- Simulation > Start...: Load XML and start simulation
" + + "- Simulation > Stop: Terminate running simulation
" + + "
Simulation Speed (Phase 3.1 global keyboard shortcuts):
" + + "- Key 1: 0.5x speed (half-time)
" + + "- Key 2: 1x speed (real-time)
" + + "- Key 3: 2x speed
" + + "- Key 4: 5x speed
" + + "- Key 5: 10x speed
" + + "- Plus (+): Increase speed by 1.5x
" + + "- Minus (-): Decrease speed by 1.5x
" + + "- Space: Pause/resume simulation" ) ) menu.add( diff --git a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControlPanel.kt b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControlPanel.kt new file mode 100644 index 000000000..2d2b9c621 --- /dev/null +++ b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControlPanel.kt @@ -0,0 +1,214 @@ +/* Brno University of Technology + * Faculty of Information Technology + * + * BSc Thesis 2006/2007 + * + * Railway Interlocking Simulator + * + * Bedrich Hovorka + */ +package cz.vutbr.fit.interlockSim.gui + +import java.awt.FlowLayout +import java.beans.PropertyChangeEvent +import java.beans.PropertyChangeListener +import java.util.Locale +import javax.swing.BorderFactory +import javax.swing.BoxLayout +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JSlider +import javax.swing.SwingConstants +import javax.swing.SwingUtilities + +/** + * Speed control panel for the simulation, implementing Phase 2.1 of Goal 7 (Issue #190). + * + * Provides: + * - A linear [JSlider] covering 0.1× to 10× in 0.1× increments + * - Seven preset buttons: 0.1×, 0.5×, 1×, 2×, 5×, 10×, 50× + * - A live speed label showing the current multiplier + * + * The slider range is 0.1×–10× (expert users reach 50× via the preset button only). + * All slider integer values are mapped to `value / SLIDER_SCALE` so that the internal + * int range [1..100] maps to double range [0.1..10.0]. + * + * **PropertyChangeListener integration:** + * - Setting [runner] installs a listener on [SimulationRunner.PROP_SPEED_MULTIPLIER] so + * that speed changes made programmatically (e.g. from tests) are reflected in the UI. + * - User interaction (slider drag, button click) writes back to [SimulationRunner.speedMultiplier]. + * - The panel is automatically hidden/shown by [cz.vutbr.fit.interlockSim.gui.Frame] when + * switching between editing and simulation modes. + * + * **Thread Safety:** + * All methods must be called from the Event Dispatch Thread (EDT). + * + * @since 2026-05-05 (Phase 2.1, Issue #190) + * @see SimulationRunner + * @see cz.vutbr.fit.interlockSim.gui.Frame + */ +class SimulationControlPanel : JPanel() { + + /** Scale factor: slider int value → speed double (1 → 0.1, 100 → 10.0). */ + private val slider: JSlider + + /** Label showing the current speed multiplier (e.g. "1.0x"). */ + private val speedLabel: JLabel + + /** + * The [SimulationRunner] currently wired to this panel, or `null` when no + * simulation is running. Setting this property: + * - Removes the listener from the old runner (if any) + * - Installs a listener on the new runner (if non-null) for [SimulationRunner.PROP_SPEED_MULTIPLIER] + * - Synchronises the slider and label to the new runner's current speed (when non-null); + * setting to `null` retains the last displayed speed so the panel is not visually reset. + * + * Must be set from the EDT. + */ + var runner: SimulationRunner? = null + set(value) { + field?.removePropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, runnerListener) + field = value + value?.addPropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, runnerListener) + if (value != null) { + syncUiToSpeed(value.speedMultiplier) + } + // When value is null, keep the current UI state so the speed display is not reset. + } + + /** Listener that keeps the UI in sync when the runner's speed changes externally. */ + private val runnerListener = PropertyChangeListener { evt: PropertyChangeEvent -> + val speed = evt.newValue as? Double ?: return@PropertyChangeListener + if (SwingUtilities.isEventDispatchThread()) { + syncUiToSpeed(speed) + } else { + SwingUtilities.invokeLater { syncUiToSpeed(speed) } + } + } + + /** + * Optional callback invoked whenever the user changes the speed (slider or preset button). + * + * Wire this in [cz.vutbr.fit.interlockSim.gui.Frame] to route speed changes through + * [SimulationController.setSpeed] so that [SimulationController.desiredSpeed] stays in sync + * and is honoured on the next [SimulationController.start] call. + * + * Not called when the runner fires a [SimulationRunner.PROP_SPEED_MULTIPLIER] event (i.e. + * when the UI is updated from the runner rather than by the user). + */ + var onSpeedChanged: ((Double) -> Unit)? = null + + /** Flag to suppress recursive slider → runner → slider feedback loops. */ + private var updatingFromRunner = false + + init { + layout = BoxLayout(this, BoxLayout.PAGE_AXIS) + border = BorderFactory.createEtchedBorder() + + // ── Row 1: slider ────────────────────────────────────────────────────── + val sliderRow = JPanel(FlowLayout(FlowLayout.LEFT, 6, 2)) + + val sliderLabel = JLabel("Speed:") + sliderRow.add(sliderLabel) + + slider = JSlider(SwingConstants.HORIZONTAL, SLIDER_MIN, SLIDER_MAX, speedToSlider(DEFAULT_SPEED)) + slider.majorTickSpacing = SLIDER_MAJOR_TICK + slider.minorTickSpacing = SLIDER_MINOR_TICK + slider.paintTicks = true + slider.paintLabels = false + slider.toolTipText = "Simulation speed: 0.1x – 10x" + slider.addChangeListener { + if (!updatingFromRunner) { + val speed = sliderToSpeed(slider.value) + speedLabel.text = formatSpeedLabel(speed) + if (onSpeedChanged != null) { + onSpeedChanged!!.invoke(speed) + } else { + runner?.speedMultiplier = speed + } + } + } + sliderRow.add(slider) + + speedLabel = JLabel(formatSpeedLabel(DEFAULT_SPEED)) + sliderRow.add(speedLabel) + + add(sliderRow) + + // ── Row 2: preset buttons ───────────────────────────────────────────── + val buttonRow = JPanel(FlowLayout(FlowLayout.LEFT, 4, 2)) + buttonRow.add(JLabel("Presets:")) + PRESETS.forEach { speed -> + val btn = JButton(formatPresetLabel(speed)) + btn.toolTipText = "Set speed to ${formatPresetLabel(speed)}" + btn.addActionListener { applyPreset(speed) } + buttonRow.add(btn) + } + + add(buttonRow) + } + + // ── Internal helpers ─────────────────────────────────────────────────────── + + /** Convert a slider int value to a speed double. */ + private fun sliderToSpeed(value: Int): Double = value / SLIDER_SCALE + + /** Convert a speed double to a slider int (rounded to nearest tick, clamped to [SLIDER_MIN]..[SLIDER_MAX]). */ + private fun speedToSlider(speed: Double): Int = + Math.round(speed * SLIDER_SCALE).toInt().coerceIn(SLIDER_MIN, SLIDER_MAX) + + /** + * Apply a preset speed: update UI and propagate to the runner. + * + * When [onSpeedChanged] is wired (normal production use via [SimulationController]), the + * speed update is routed exclusively through the callback to avoid a double-write to the + * runner. When [onSpeedChanged] is null (standalone panel usage without a controller), + * the runner is updated directly so the panel remains functional. + */ + private fun applyPreset(speed: Double) { + syncUiToSpeed(speed) + if (onSpeedChanged != null) { + onSpeedChanged!!.invoke(speed) + } else { + runner?.speedMultiplier = speed + } + } + + /** + * Synchronise both slider and label to [speed] without triggering the + * slider's change listener feedback loop. + */ + private fun syncUiToSpeed(speed: Double) { + updatingFromRunner = true + try { + val sliderValue = speedToSlider(speed) + if (slider.value != sliderValue) { + slider.value = sliderValue + } + speedLabel.text = formatSpeedLabel(speed) + } finally { + updatingFromRunner = false + } + } + + private fun formatSpeedLabel(speed: Double): String = "%.1fx".format(Locale.ROOT, speed) + + private fun formatPresetLabel(speed: Double): String = + if (speed >= 1.0) "%.0fx".format(Locale.ROOT, speed) else "%.1fx".format(Locale.ROOT, speed) + + companion object { + /** Slider integer range: [1..100] maps to speed [0.1..10.0]. */ + private const val SLIDER_MIN: Int = 1 + private const val SLIDER_MAX: Int = 100 + private const val SLIDER_SCALE: Double = 10.0 + private const val SLIDER_MAJOR_TICK: Int = 10 + private const val SLIDER_MINOR_TICK: Int = 5 + + /** Default speed for the panel (1.0× = real-time). */ + const val DEFAULT_SPEED: Double = 1.0 + + /** Seven preset speed multipliers. Values above 10× exceed the slider range. */ + val PRESETS: List = listOf(0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0) + } +} diff --git a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationController.kt b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationController.kt new file mode 100644 index 000000000..733d4dad1 --- /dev/null +++ b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationController.kt @@ -0,0 +1,241 @@ +/* + Brno University of Technology + Faculty of Information Technology + + BSc Thesis 2006/2007 + Railway Interlocking Simulator + + SimulationController — simulation lifecycle logic extracted from Frame (Issue #189) +*/ + +package cz.vutbr.fit.interlockSim.gui + +import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext +import cz.vutbr.fit.interlockSim.context.SimulationContext +import cz.vutbr.fit.interlockSim.sim.SpeedControllable +import io.github.oshai.kotlinlogging.KotlinLogging +import java.beans.PropertyChangeListener + +/** + * Manages the simulation lifecycle on behalf of [Frame] (Issue #189). + * + * Encapsulates all simulation-runner state so that the logic can be unit-tested + * independently of the Swing [Frame] window hierarchy (which requires a display). + * + * ## Responsibilities + * - Creating and owning the [SimulationRunner] instance + * - Starting the runner synchronously (before the monitor thread) to prevent the + * race condition where [stop] is called before the monitor thread starts the runner + * - Polling for completion on a daemon "SimulationMonitor" thread + * - Reporting lifecycle and speed changes via callbacks + * + * ## Thread Safety + * - [start] and [stop] are designed to be called from the same thread. They are NOT + * thread-safe for concurrent calls from different threads; external callers are + * responsible for serialization. + * - [runner] is `@Volatile` so the monitor thread reads a fresh value when [stop] + * nulls it. + * - Callbacks are invoked on whichever thread performs the lifecycle change. + * + * @param onStateChanged Callback for lifecycle state updates. Invoked on the same + * thread that performs the state change (caller thread for [start]/[stop], monitor + * thread for natural completion). + * @param onSpeedChanged Callback for speed indicator updates. Invoked on the thread + * that emits the speed change; callers are responsible for EDT marshalling as needed. + * @param onCompleted Callback invoked when the simulation finishes naturally on the + * monitor thread. Defaults to a no-op. + * @since 2026-04-20 (extracted from Frame for testability) + * @see Frame + */ +internal class SimulationController( + private val onStateChanged: (SimulationStatus) -> Unit = {}, + private val onSpeedChanged: (Double) -> Unit = {}, + private val onCompleted: () -> Unit = {}, +) { + /** + * The currently active runner, or `null` when no simulation is running. + * + * `@Volatile` ensures the monitor thread reads a fresh value after [stop] nulls it. + */ + @Volatile + var runner: SimulationRunner? = null + private set + + /** Listener registered on the active runner for speed changes; removed on stop. */ + private var speedListener: PropertyChangeListener? = null + + /** + * Reference to the running main process when it implements [SpeedControllable] + * (e.g. [cz.vutbr.fit.interlockSim.sim.ShuntingLoop]). `setSpeed` propagates + * speed changes here so the simulation thread's wall-clock pacing actually + * tracks the GUI controls — without this link the runner-side `speedMultiplier` + * is dead-state w.r.t. RealTimeSynch. + */ + @Volatile + private var speedControllable: SpeedControllable? = null + + /** + * Desired speed multiplier applied to new and currently running simulations. + * + * Stored so that a speed selection before [start] is honoured once the runner is created. + */ + private var desiredSpeed: Double = SimulationRunner.DEFAULT_SPEED + + /** + * Current effective speed: the live runner's speed when a simulation is running, + * otherwise the stored [desiredSpeed] that will be applied on the next [start]. + */ + val speed: Double get() = runner?.speedMultiplier ?: desiredSpeed + + /** + * Start the simulation for [context]. + * + * Idempotent: if a simulation is already running this is a no-op. + * + * Steps: + * 1. Creates [SimulationRunner] wrapping [context]. + * 2. Calls [SimulationRunner.start] **synchronously** (before the monitor thread) to + * eliminate the race condition where [stop] could be invoked before the monitor + * thread has a chance to start the runner. + * 3. Emits [SimulationStatus.RUNNING] via [onStateChanged]. + * 4. Launches a daemon "SimulationMonitor" thread that polls [SimulationRunner.isRunning] + * and on completion emits [SimulationStatus.STOPPED] and invokes [onCompleted]. + * + * @param context The simulation context to run. + */ + fun start(context: SimulationContext) { + val existing = runner + if (existing != null && existing.isRunning()) { + logger.debug { "start ignored — simulation already running" } + return + } + + // Clean up any stale speed listener from a previous run that finished naturally + // (monitor thread may still be in its finally block when we get here). + if (existing != null && !existing.isRunning()) { + cleanupSpeedListener(existing) + } + + val newRunner = SimulationRunner(context) + newRunner.speedMultiplier = desiredSpeed + runner = newRunner + val mainProcess = (context as? DefaultSimulationContext)?.getMainProcess() + val controllable = mainProcess as? SpeedControllable + speedControllable = controllable + controllable?.speedMultiplier = desiredSpeed + + // Start synchronously BEFORE enabling the Stop button or launching the monitor + // thread. This ensures stopSimulation() always has a live thread to interrupt. + newRunner.start() + + // Wire speed callback for SimulationRunner speed changes. + // The listener is removed when the simulation stops (in stop() or monitor finally). + val listener = PropertyChangeListener { evt -> + val multiplier = evt.newValue as? Double + if (multiplier == null) { + logger.debug { "Ignoring unexpected ${SimulationRunner.PROP_SPEED_MULTIPLIER} value: ${evt.newValue}" } + return@PropertyChangeListener + } + onSpeedChanged(multiplier) + } + speedListener = listener + newRunner.addPropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, listener) + onSpeedChanged(newRunner.speedMultiplier) + + onStateChanged(SimulationStatus.RUNNING) + + launchMonitorThread(newRunner) + } + + /** + * Launch a daemon "SimulationMonitor" thread that polls [newRunner] for completion + * and dispatches callback notifications when done. + * + * Guards against stale-monitor by checking `runner === newRunner` before mutating + * state so that a stop+start cycle cannot clobber the new run's state. + */ + private fun launchMonitorThread(newRunner: SimulationRunner) { + val monitorThread = + Thread( + { + try { + while (newRunner.isRunning()) { + Thread.sleep(SIMULATION_POLL_INTERVAL_MS) + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + // Guard against stale-monitor: if stop() + start(ctxB) ran before + // this callback executes, runner has been replaced with a new instance. + // Skip reset to avoid clobbering the new run's state. + if (runner === newRunner) { + cleanupSpeedListener(newRunner) + runner = null + speedControllable = null + onSpeedChanged(SimulationRunner.DEFAULT_SPEED) + onStateChanged(SimulationStatus.STOPPED) + onCompleted() + } + } + }, + "SimulationMonitor" + ) + monitorThread.isDaemon = true + monitorThread.start() + } + + /** + * Stop a running simulation and emit [SimulationStatus.STOPPED]. + * + * Safe to call when no simulation is running (no-op in that case). + */ + fun stop() { + val r = runner ?: return + cleanupSpeedListener(r) + r.stop() + runner = null + speedControllable = null + onSpeedChanged(SimulationRunner.DEFAULT_SPEED) + onStateChanged(SimulationStatus.STOPPED) + } + + /** Removes the speed [PropertyChangeListener] from [r] and clears the reference. */ + private fun cleanupSpeedListener(r: SimulationRunner) { + speedListener?.let { r.removePropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, it) } + speedListener = null + } + + /** Returns `true` while the underlying [SimulationRunner] reports running. */ + fun isRunning(): Boolean = runner?.isRunning() ?: false + + /** + * Set the simulation speed multiplier. + * + * Applied immediately to the currently running simulation (if any) and stored + * so it is also honoured by the next [start] call. + * + * @param multiplier Speed factor in [SimulationRunner.MIN_SPEED]..[SimulationRunner.MAX_SPEED]. + * @throws IllegalArgumentException if [multiplier] is outside the valid range. + */ + fun setSpeed(multiplier: Double) { + require(multiplier in SimulationRunner.MIN_SPEED..SimulationRunner.MAX_SPEED) { + "speedMultiplier must be in [${SimulationRunner.MIN_SPEED}..${SimulationRunner.MAX_SPEED}], got: $multiplier" + } + desiredSpeed = multiplier + runner?.speedMultiplier = multiplier + speedControllable?.speedMultiplier = multiplier + } + + companion object { + private val logger = KotlinLogging.logger {} + + /** Poll interval (ms) for the monitor thread to detect simulation completion. */ + internal const val SIMULATION_POLL_INTERVAL_MS: Long = 100L + } + + /** Simulation lifecycle states emitted via [onStateChanged]. */ + enum class SimulationStatus { + RUNNING, + STOPPED, + } +} diff --git a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationKeyBindings.kt b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationKeyBindings.kt new file mode 100644 index 000000000..c005a3ec6 --- /dev/null +++ b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationKeyBindings.kt @@ -0,0 +1,219 @@ +/* Brno University of Technology + * Faculty of Information Technology + * + * BSc Thesis 2006/2007 + * + * Railway Interlocking Simulator + * + * SimulationKeyBindings — Phase 3.1: Global Keyboard Shortcuts (Issue #193) + */ +package cz.vutbr.fit.interlockSim.gui + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent +import javax.swing.AbstractAction +import javax.swing.JComponent +import javax.swing.KeyStroke + +/** + * Global keyboard shortcuts for simulation speed control (Phase 3.1 of Goal 7, Issue #193). + * + * Provides: + * - Number keys 1-5 → Speed presets (0.5×, 1×, 2×, 5×, 10×) + * - Plus/minus keys → Incremental speed adjustment (×1.5 or ÷1.5) + * - Space bar → Pause/resume toggle (Goal 8 preparation) + * + * All bindings use [JComponent.WHEN_IN_FOCUSED_WINDOW] scope so they work whenever + * the [Frame] has focus, regardless of which component has keyboard focus. + * + * ## Usage + * ```kotlin + * val keyBindings = SimulationKeyBindings(frame.simulationController) + * keyBindings.install(frame.rootPane) + * ``` + * + * **Thread Safety:** + * - [install] and [uninstall] must be called from EDT + * - Actions are executed on EDT by Swing's key binding mechanism + * + * @param simulationController The controller managing the active simulation runner + * @since 2026-05-06 (Phase 3.1, Issue #193) + * @see SimulationController + * @see Frame + */ +internal class SimulationKeyBindings( + private val simulationController: SimulationController +) { + private val logger = KotlinLogging.logger {} + + /** + * Install keyboard shortcuts on the given [rootPane]. + * + * Binds keys to actions using [JComponent.WHEN_IN_FOCUSED_WINDOW] scope so + * they work globally when the window has focus. + * + * Safe to call multiple times (idempotent). Previous bindings are replaced + * by subsequent calls. + * + * @param rootPane The root pane to install bindings on (typically [Frame.rootPane]) + */ + fun install(rootPane: JComponent) { + val inputMap = rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) + val actionMap = rootPane.actionMap + + // Speed presets: keys 1-5 map to 0.5×, 1×, 2×, 5×, 10× + PRESET_BINDINGS.forEach { (keyCode, speed) -> + val actionKey = "speed_preset_$speed" + inputMap.put(KeyStroke.getKeyStroke(keyCode, 0), actionKey) + actionMap.put(actionKey, SpeedPresetAction(speed)) + } + + // Incremental speed adjustment: + increases by ×1.5, - decreases by ÷1.5 + // '+' is typically Shift+'=' on most keyboards, so bind both Shift+VK_EQUALS and numpad plus + inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK), ACTION_KEY_SPEED_UP) + inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_ADD, 0), ACTION_KEY_SPEED_UP) // Numpad + + inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, 0), ACTION_KEY_SPEED_DOWN) + inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, 0), ACTION_KEY_SPEED_DOWN) // Numpad - + actionMap.put(ACTION_KEY_SPEED_UP, IncrementalSpeedAction(multiplier = SPEED_INCREMENT)) + actionMap.put(ACTION_KEY_SPEED_DOWN, IncrementalSpeedAction(multiplier = 1.0 / SPEED_INCREMENT)) + + // Pause/resume toggle: Space bar (Goal 8 preparation) + inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), ACTION_KEY_PAUSE_TOGGLE) + actionMap.put(ACTION_KEY_PAUSE_TOGGLE, PauseToggleAction()) + + logger.debug { "Simulation keyboard shortcuts installed" } + } + + /** + * Uninstall keyboard shortcuts from the given [rootPane]. + * + * Removes all key bindings and actions installed by [install]. + * Safe to call when not installed (no-op). + * + * @param rootPane The root pane to uninstall bindings from + */ + fun uninstall(rootPane: JComponent) { + val inputMap = rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) + val actionMap = rootPane.actionMap + + // Remove preset bindings + PRESET_BINDINGS.forEach { (keyCode, speed) -> + val actionKey = "speed_preset_$speed" + inputMap.remove(KeyStroke.getKeyStroke(keyCode, 0)) + actionMap.remove(actionKey) + } + + // Remove incremental speed bindings + inputMap.remove(KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK)) + inputMap.remove(KeyStroke.getKeyStroke(KeyEvent.VK_ADD, 0)) + inputMap.remove(KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, 0)) + inputMap.remove(KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, 0)) + actionMap.remove(ACTION_KEY_SPEED_UP) + actionMap.remove(ACTION_KEY_SPEED_DOWN) + + // Remove pause toggle binding + inputMap.remove(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0)) + actionMap.remove(ACTION_KEY_PAUSE_TOGGLE) + + logger.debug { "Simulation keyboard shortcuts uninstalled" } + } + + // ── Action implementations ───────────────────────────────────────────────── + + /** + * Action that sets the simulation speed to a fixed preset value. + * + * If no simulation is running, [SimulationController.setSpeed] updates [SimulationController.desiredSpeed] + * which will be applied when the next simulation starts. + */ + private inner class SpeedPresetAction(private val speed: Double) : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + try { + simulationController.setSpeed(speed) + logger.debug { "Speed preset applied: ${speed}×" } + } catch (ex: IllegalArgumentException) { + logger.warn { "Invalid speed preset: $speed — ${ex.message}" } + } + } + } + + /** + * Action that adjusts the simulation speed by a multiplicative factor. + * + * Reads the current effective speed via [SimulationController.speed] (live runner value + * when running, stored desired speed otherwise), multiplies by [multiplier], coerces to + * the valid range [SimulationRunner.MIN_SPEED]..[SimulationRunner.MAX_SPEED], and applies + * the new speed via [SimulationController.setSpeed]. + * + * When no simulation is running, this updates [SimulationController.desiredSpeed] so the + * adjusted speed is honoured when the next simulation starts. + */ + private inner class IncrementalSpeedAction(private val multiplier: Double) : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val currentSpeed = simulationController.speed + val newSpeed = (currentSpeed * multiplier).coerceIn( + SimulationRunner.MIN_SPEED, + SimulationRunner.MAX_SPEED + ) + + try { + simulationController.setSpeed(newSpeed) + logger.debug { "Speed adjusted: ${currentSpeed}× → ${newSpeed}× (×$multiplier)" } + } catch (ex: IllegalArgumentException) { + logger.warn { "Invalid speed adjustment: $newSpeed — ${ex.message}" } + } + } + } + + /** + * Action that toggles pause/resume state of the simulation. + * + * Reads the current [SimulationRunner.isPaused] flag and flips it. + * If no simulation is running, the action is a no-op. + * + * **Goal 8 preparation:** This is a minimal implementation that directly toggles + * the [SimulationRunner.isPaused] property. Future work (Goal 8) will wire this + * into UI elements and add comprehensive pause/resume state management. + */ + private inner class PauseToggleAction : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val runner = simulationController.runner + if (runner == null) { + logger.debug { "Pause toggle ignored (no simulation running)" } + return + } + + val wasPaused = runner.isPaused + runner.isPaused = !wasPaused + logger.debug { "Simulation ${if (wasPaused) "resumed" else "paused"}" } + } + } + + companion object { + /** Action key for speed-up shortcut. */ + private const val ACTION_KEY_SPEED_UP = "simulation_speed_up" + + /** Action key for speed-down shortcut. */ + private const val ACTION_KEY_SPEED_DOWN = "simulation_speed_down" + + /** Action key for pause/resume toggle. */ + private const val ACTION_KEY_PAUSE_TOGGLE = "simulation_pause_toggle" + + /** Incremental speed multiplier: ×1.5 for speed-up, ÷1.5 for speed-down. */ + private const val SPEED_INCREMENT = 1.5 + + /** + * Mapping of key codes to speed preset values. + * Keys 1-5 → 0.5×, 1×, 2×, 5×, 10× (matches the five standard presets in the UI panel and menu). + * Note: 0.5× is accessible only via keyboard (key 1), panel, or menu — not via incremental shortcuts. + */ + private val PRESET_BINDINGS: Map = mapOf( + KeyEvent.VK_1 to 0.5, + KeyEvent.VK_2 to 1.0, + KeyEvent.VK_3 to 2.0, + KeyEvent.VK_4 to 5.0, + KeyEvent.VK_5 to 10.0 + ) + } +} diff --git a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/StatusBar.kt b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/StatusBar.kt index ea371c384..efd158106 100644 --- a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/StatusBar.kt +++ b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/StatusBar.kt @@ -13,18 +13,34 @@ import cz.vutbr.fit.interlockSim.PROGRAM_NAME import cz.vutbr.fit.interlockSim.context.ContextChangeListener import cz.vutbr.fit.interlockSim.exceptions.requireValidState import cz.vutbr.fit.interlockSim.objects.core.ContextChangeEvent +import java.awt.BorderLayout import java.awt.Component import java.awt.Dimension import java.awt.event.MouseEvent import java.awt.event.MouseMotionListener +import java.util.Locale import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.SwingUtilities +import kotlin.math.abs /** - * Status bar for displaying context information and mouse motion status + * Status bar for displaying context information and mouse motion status. + * + * Implemented as a [JPanel] containing two labels: + * - [statusLabel] (CENTER): shows context property-change messages and mouse-position info. + * - [speedLabel] (EAST): shows the current simulation speed multiplier when it differs from + * 1.0x; hidden at default speed. Written only from EDT via [updateSpeedIndicator]. + * + * Separating the two labels avoids the conflict where simulation-thread property-change + * callbacks (via [ContextChangeListener]) would otherwise overwrite the speed indicator text. */ class StatusBar : - JLabel(), + JPanel(), ContextChangeListener { + private val statusLabel = JLabel() + private val speedLabel = JLabel().apply { isVisible = false } + private val mouseListener = object : MouseMotionListener { override fun mouseDragged(e: MouseEvent) { @@ -39,7 +55,17 @@ class StatusBar : } } + /** Delegates to [statusLabel], providing a backward-compatible text property. */ + var text: String + get() = statusLabel.text ?: "" + set(value) { + statusLabel.text = value + } + init { + layout = BorderLayout() + add(statusLabel, BorderLayout.CENTER) + add(speedLabel, BorderLayout.EAST) preferredSize = Dimension(100, 25) text = "Welcome to " + PROGRAM_NAME } @@ -60,10 +86,12 @@ class StatusBar : } override fun propertyChange(event: ContextChangeEvent) { - val newValue = event.newValue - when { - newValue is CharSequence -> text = newValue.toString() - newValue != null -> text = newValue.toString() + val newValue = event.newValue ?: return + val newText = newValue.toString() + if (SwingUtilities.isEventDispatchThread()) { + text = newText + } else { + SwingUtilities.invokeLater { text = newText } } } @@ -89,4 +117,46 @@ class StatusBar : timer.isRepeats = false timer.start() } + + /** + * Updates the speed indicator in [speedLabel] (separate from [statusLabel]). + * + * When [multiplier] differs from 1.0x, [speedLabel] shows "Speed: X.Xx" and becomes + * visible. At default speed (1.0x) the label is hidden and its text is cleared so + * no stale speed string is shown if the status bar later becomes visible again. + * + * This method is EDT-safe: it executes synchronously when already on the EDT (so + * [SimulationController.stop] on the EDT takes effect before subsequent mode-switch + * calls), and uses [SwingUtilities.invokeLater] when called from a background thread. + * + * @param multiplier Current speed multiplier from [SimulationRunner] + */ + fun updateSpeedIndicator(multiplier: Double) { + if (SwingUtilities.isEventDispatchThread()) { + applySpeedIndicator(multiplier) + } else { + SwingUtilities.invokeLater { applySpeedIndicator(multiplier) } + } + } + + private fun applySpeedIndicator(multiplier: Double) { + if (abs(multiplier - DEFAULT_SPEED) > SPEED_EPSILON) { + speedLabel.text = String.format(Locale.ROOT, "Speed: %.1fx", multiplier) + speedLabel.isVisible = true + } else { + speedLabel.text = "" + speedLabel.isVisible = false + } + } + + /** Returns `true` when the speed indicator label is currently visible. */ + internal fun isSpeedIndicatorVisible(): Boolean = speedLabel.isVisible + + /** Returns the current speed indicator text, or an empty string when hidden. */ + internal fun speedIndicatorText(): String = speedLabel.text ?: "" + + companion object { + private const val DEFAULT_SPEED = 1.0 + private const val SPEED_EPSILON = 0.001 + } } diff --git a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/ToolBar.kt b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/ToolBar.kt index 20d1db025..1c15e4f45 100644 --- a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/ToolBar.kt +++ b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/ToolBar.kt @@ -23,6 +23,7 @@ import java.awt.Dimension import java.awt.event.ActionEvent import javax.swing.AbstractAction import javax.swing.ButtonGroup +import javax.swing.JLabel import javax.swing.JToggleButton import javax.swing.JToolBar @@ -121,4 +122,37 @@ class ToolBar : JToolBar() { component.isEnabled = enabled } } + + private val simControlsSeparator: JToolBar.Separator = JToolBar.Separator() + private val simControlsLabel: JLabel = JLabel("▶ Simulation") + + /** + * Shows the simulation controls panel in the toolbar. + * + * Called when a simulation starts to indicate simulation mode visually. + * Idempotent: safe to call if controls are already showing. + * Must be called from the Event Dispatch Thread (EDT). + */ + fun showSimulationControls() { + if (simControlsLabel.parent != null) return + add(simControlsSeparator) + add(simControlsLabel) + revalidate() + repaint() + } + + /** + * Hides the simulation controls panel from the toolbar. + * + * Called when a simulation stops to return to editing mode appearance. + * Idempotent: safe to call if controls are not currently showing. + * Must be called from the Event Dispatch Thread (EDT). + */ + fun hideSimulationControls() { + if (simControlsLabel.parent == null) return + remove(simControlsSeparator) + remove(simControlsLabel) + revalidate() + repaint() + } } diff --git a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/animation/ControlPanel.kt b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/animation/ControlPanel.kt index 1c9f80584..cf2fc6d5a 100644 --- a/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/animation/ControlPanel.kt +++ b/desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/animation/ControlPanel.kt @@ -2,6 +2,7 @@ package cz.vutbr.fit.interlockSim.gui.animation import java.awt.FlowLayout import javax.swing.BorderFactory +import javax.swing.JButton import javax.swing.JLabel import javax.swing.JPanel @@ -9,15 +10,14 @@ import javax.swing.JPanel * Control panel for displaying simulation time and status during animated simulation. * * This component appears at the top of the frame during simulation mode and is hidden - * during editing mode. It provides real-time feedback on simulation progress without - * simulation control buttons (due to kDisco framework limitations - simulations cannot - * be paused, only started and stopped). + * during editing mode. It provides real-time feedback on simulation progress and a + * Stop button to terminate the running simulation. * * **Layout Structure:** * ``` - * ┌─────────────────────────────────────────────┐ - * │ Time: 00:12:34.567 Status: Running │ - * └─────────────────────────────────────────────┘ + * ┌────────────────────────────────────────────────────────┐ + * │ Time: 00:12:34.567 Status: Running [Stop] │ + * └────────────────────────────────────────────────────────┘ * ``` * * **Update Frequency:** @@ -28,9 +28,10 @@ import javax.swing.JPanel * All methods must be called from the Event Dispatch Thread (EDT). * * **Design Constraints:** - * - No pause/resume buttons: kDisco simulations cannot be paused once started + * - No pause/resume button: kDisco simulations cannot be paused once started + * - Stop button: enabled while a simulation is running; calls [onStop] callback * - Time formatting: HH:MM:SS.mmm (hours:minutes:seconds.milliseconds) - * - Status values: "Ready", "Running", "Stopped" + * - Status values: [SimulationStatus.READY], [SimulationStatus.RUNNING], [SimulationStatus.STOPPED] * * @since 2026-01-22 * @see cz.vutbr.fit.interlockSim.gui.Frame @@ -47,6 +48,18 @@ class ControlPanel : JPanel() { */ private val statusLabel: JLabel + /** + * Button that requests simulation termination. + * Enabled only while a simulation is running. + */ + private val stopButton: JButton + + /** + * Callback invoked when the Stop button is clicked. + * Set by [cz.vutbr.fit.interlockSim.gui.Frame] before starting a simulation. + */ + var onStop: (() -> Unit)? = null + init { layout = FlowLayout(FlowLayout.LEFT, 10, 5) border = BorderFactory.createEtchedBorder() @@ -56,8 +69,28 @@ class ControlPanel : JPanel() { add(timeLabel) // Create status label with initial state - statusLabel = JLabel("Status: Ready") + statusLabel = JLabel("Status: ${SimulationStatus.READY.displayName}") add(statusLabel) + + // Create stop button (disabled until simulation starts) + stopButton = JButton("Stop") + stopButton.isEnabled = false + stopButton.addActionListener { onStop?.invoke() } + add(stopButton) + } + + /** + * Enable or disable the Stop button. + * + * Should be `true` while simulation is running, `false` otherwise. + * + * **Thread Safety:** + * Must be called from the Event Dispatch Thread (EDT). + * + * @param enabled Whether the Stop button should respond to clicks + */ + fun setStopEnabled(enabled: Boolean) { + stopButton.isEnabled = enabled } /** @@ -91,18 +124,13 @@ class ControlPanel : JPanel() { /** * Updates the displayed simulation status. * - * **Valid Status Values:** - * - "Ready" - Simulation initialized but not started - * - "Running" - Simulation actively executing - * - "Stopped" - Simulation terminated - * * **Thread Safety:** * Must be called from the Event Dispatch Thread (EDT). * - * @param status The new status to display (typically "Ready", "Running", or "Stopped") + * @param status The new status to display */ - fun updateStatus(status: String) { - statusLabel.text = "Status: $status" + fun updateStatus(status: SimulationStatus) { + statusLabel.text = "Status: ${status.displayName}" } /** @@ -135,4 +163,20 @@ class ControlPanel : JPanel() { return String.format("%02d:%02d:%02d.%03d", hours, minutes, seconds, milliseconds) } + + /** + * Simulation status values used to control the [statusLabel] display. + * + * @property displayName Human-readable label text shown in the UI. + */ + enum class SimulationStatus(val displayName: String) { + /** Context set but simulation not yet started. */ + READY("Ready"), + + /** Simulation is actively executing. */ + RUNNING("Running"), + + /** Simulation has finished or was stopped. */ + STOPPED("Stopped"), + } } diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/MainArgumentParsingTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/MainArgumentParsingTest.kt index 19b7a1cb9..c87cf3525 100644 --- a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/MainArgumentParsingTest.kt +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/MainArgumentParsingTest.kt @@ -153,6 +153,47 @@ class MainArgumentParsingTest { assertThat(usageNotPrinted).isTrue() } + @Test + @DisplayName("parseMode returns simgui for simgui argument (no GUI launched)") + fun `parseMode returns simgui for simgui argument`() { + // parseMode is a pure function — safe to call without DI or Swing UI + assertThat(parseMode(arrayOf("simgui"))).isEqualTo("simgui") + } + + @Test + @DisplayName("parseMode returns null for unknown argument") + fun `parseMode returns null for unknown argument`() { + assertThat(parseMode(arrayOf("unknown"))).isEqualTo(null) + } + + @Test + @DisplayName("parseMode returns null for empty argument list") + fun `parseMode returns null for empty argument list`() { + assertThat(parseMode(emptyArray())).isEqualTo(null) + } + + @Test + @DisplayName("parseMode recognises all supported modes") + fun `parseMode recognises all supported modes`() { + for (mode in listOf("sim", "simgui", "edit", "example", "exampleGui")) { + assertThat(parseMode(arrayOf(mode))).isEqualTo(mode) + } + } + + @Disabled( + "simgui schedules Swing GUI creation on the EDT; calling main(\"simgui\") can " + + "create Swing UI and fail asynchronously in headless environments. " + + "Argument parsing for GUI modes is covered by dedicated " + + "integration tests (see MainEditModeTest for the edit-mode equivalent pattern)." + ) + @Test + fun `simgui mode selected with simgui argument`() { + // GUI mode invocation is intentionally not exercised here because calling + // main(arrayOf("simgui")) can create Swing UI and fail asynchronously in + // headless environments. Argument parsing for GUI modes should be covered + // by dedicated GUI/integration tests or by the pure parseMode() function above. + } + // NOTE: edit mode test moved to MainEditModeTest.kt to ensure proper Frame disposal // See GitHub Issue #111 - Frame tests must extend AbstractFrameTestBase @@ -451,6 +492,7 @@ class MainArgumentParsingTest { val output = getCapturedError() assertThat(output).contains("usage:") assertThat(output).contains("sim") + assertThat(output).contains("simgui") assertThat(output).contains("edit") assertThat(output).contains("example") } diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/FrameSimulationLifecycleTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/FrameSimulationLifecycleTest.kt new file mode 100644 index 000000000..ee4a8bf6c --- /dev/null +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/FrameSimulationLifecycleTest.kt @@ -0,0 +1,209 @@ +/* + Brno University of Technology + Faculty of Information Technology + + BSc Thesis 2006/2007 + Railway Interlocking Simulator + + Integration tests for Frame simulation lifecycle (Issue #189) + Tests require a non-headless display — skipped automatically in CI. +*/ + +package cz.vutbr.fit.interlockSim.gui + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isFalse +import assertk.assertions.isNotEqualTo +import assertk.assertions.isTrue +import cz.vutbr.fit.interlockSim.PROGRAM_FULL_NAME +import cz.vutbr.fit.interlockSim.testutil.TestFixtures +import cz.vutbr.fit.interlockSim.testutil.createMockSimulationContext +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Integration tests for the Frame simulation lifecycle (Issue #189). + * + * Verifies that [Frame.startSimulation], [Frame.stopSimulation], and [Frame.setContext] + * interact correctly with [SimulationController] to produce correct UI state transitions. + * + * These tests extend [AbstractFrameTestBase]: + * - Tagged as `@Tag("integration-test")` — run via `./gradlew integrationTest` + * - Skipped automatically in headless CI environments (no X11 display) + * - Frame instances are disposed automatically via [AbstractFrameTestBase.tearDown] + * + * Tests cover: + * 1. [Frame.setContext] switches to simulation mode with Stop button disabled + * 2. [Frame.startSimulation] starts runner and enables Stop button + * 3. [Frame.stopSimulation] stops runner and disables Stop button + * 4. [Frame.setContext] while simulation is running stops the previous simulation + * 5. [Frame.stopSimulation] is a no-op when no simulation is running + * 6. [Frame.modificationTracker] `setCurrentFile` updates window title (mirrors edit mode) + * + * @see AbstractFrameTestBase + * @see SimulationController + * @see SimulationControllerTest for headless unit tests + */ +@DisplayName("Frame Simulation Lifecycle") +class FrameSimulationLifecycleTest : AbstractFrameTestBase() { + private lateinit var frame: Frame + + @BeforeEach + override fun setUp() { + super.setUp() // checks for headless; skips test if no display + runOnEDT { + frame = Frame() + frames.add(frame) // registered for auto-disposal in tearDown() + } + } + + @AfterEach + override fun tearDown() { + // Guard against setUp() being aborted by the headless assumption check + // (frame would not be initialized in that case) + if (this::frame.isInitialized) { + runOnEDT { + frame.stopSimulation() + } + } + super.tearDown() + } + + // ── setContext(SimulationContext) ───────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("setContext(SimulationContext) switches to simulation mode, Stop button disabled") + fun setContextSwitchesToSimulationMode() { + val context = createMockSimulationContext(TestFixtures.loadShuntingXml()) + + runOnEDT { + frame.setContext(context) + // Stop button starts disabled — only enabled after startSimulation() + assertThat(frame.simulationController.isRunning()).isFalse() + } + + context.close() + } + + // ── startSimulation ─────────────────────────────────────────────────────── + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("startSimulation starts the simulation runner") + fun startSimulationStartsRunner() { + val runStarted = CountDownLatch(1) + val context = createMockSimulationContext(TestFixtures.loadShuntingXml()) + context.addPropertyChangeListener { _ -> runStarted.countDown() } + + runOnEDT { + frame.setContext(context) + frame.startSimulation() + } + + // Wait for simulation runner to spin up via property-change notification + assertThat(runStarted.await(5, TimeUnit.SECONDS)).isTrue() + + // Key assertion: startSimulation() must not throw; simulation either running + // or completed naturally — both are valid for a quick mock context + runOnEDT { + frame.stopSimulation() + } + context.close() + } + + // ── stopSimulation ──────────────────────────────────────────────────────── + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("stopSimulation stops a running simulation and marks as not running") + fun stopSimulationStopsRunner() { + val started = CountDownLatch(1) + val context = createMockSimulationContext(TestFixtures.loadShuntingXml()) + + // Hook into context to know when simulation is running + context.addPropertyChangeListener { _ -> started.countDown() } + + runOnEDT { + frame.setContext(context) + frame.startSimulation() + } + + // Simulation may start quickly; either way proceed to stop + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + runOnEDT { + frame.stopSimulation() + assertThat(frame.simulationController.isRunning()).isFalse() + } + + context.close() + } + + // ── setContext while running ────────────────────────────────────────────── + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("setContext while simulation is running stops the previous simulation") + fun setContextWhileRunningStopsPreviousSimulation() { + val context1 = createMockSimulationContext(TestFixtures.loadShuntingXml()) + val context2 = createMockSimulationContext(TestFixtures.loadShuntingXml()) + + val run1Started = CountDownLatch(1) + context1.addPropertyChangeListener { _ -> run1Started.countDown() } + + runOnEDT { + frame.setContext(context1) + frame.startSimulation() + } + + // Wait for first simulation to actually start before switching context + assertThat(run1Started.await(5, TimeUnit.SECONDS)).isTrue() + + runOnEDT { + // setContext calls stopSimulation() internally before switching + frame.setContext(context2) + // After context switch, previous simulation must be stopped + assertThat(frame.simulationController.isRunning()).isFalse() + } + + runOnEDT { frame.stopSimulation() } + context1.close() + context2.close() + } + + // ── stopSimulation no-op ────────────────────────────────────────────────── + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("stopSimulation is a no-op when no simulation is running") + fun stopSimulationIsNoopWhenNotRunning() { + runOnEDT { + // Should not throw when called without a running simulation + frame.stopSimulation() + assertThat(frame.simulationController.isRunning()).isFalse() + } + } + + // ── window title (mirrors edit-mode file marker) ────────────────────────── + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("modificationTracker setCurrentFile updates window title like edit mode") + fun modificationTrackerSetCurrentFileUpdatesTitle() { + val file = File("vyhybna.xml") + runOnEDT { + frame.modificationTracker.setCurrentFile(file) + // Title should now contain the filename + assertThat(frame.title).contains("vyhybna.xml") + assertThat(frame.title).isNotEqualTo(PROGRAM_FULL_NAME) + } + } +} diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/FrameTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/FrameTest.kt index dac688a4e..3a5b5d8d4 100644 --- a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/FrameTest.kt +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/FrameTest.kt @@ -15,6 +15,7 @@ import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import assertk.assertions.isNotNull +import assertk.assertions.isTrue import cz.vutbr.fit.interlockSim.PROGRAM_FULL_NAME import cz.vutbr.fit.interlockSim.context.EditingContext import cz.vutbr.fit.interlockSim.context.EditingContextFactory @@ -133,25 +134,29 @@ class FrameTest : AbstractFrameTestBase() { @DisplayName("frame has toolbar container at north") fun frameHasToolbarContainerAtNorth() { runOnEDT { - // North component is now a JPanel containing ToolBar + ControlPanel (Issue #205) + // North component is now a JPanel containing ToolBar + ControlPanel + SimulationControlPanel (Issues #205, #190) val northComponent = (frame.contentPane.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) assertThat(northComponent).isNotNull() assertThat(northComponent).isInstanceOf(javax.swing.JPanel::class) - // Verify the container has components (ToolBar and ControlPanel) + // Verify the container has components (ToolBar, ControlPanel, SimulationControlPanel) val panel = northComponent as javax.swing.JPanel - assertThat(panel.componentCount).isEqualTo(2) + assertThat(panel.componentCount).isEqualTo(3) } } @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) - @DisplayName("frame has status bar at south") + @DisplayName("frame has status bar in south panel") fun frameHasStatusBarAtSouth() { runOnEDT { + // SOUTH now contains a southPanel (JPanel) that wraps StatusBar and EventTimelinePanel val southComponent = (frame.contentPane.layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH) assertThat(southComponent).isNotNull() - assertThat(southComponent).isInstanceOf(StatusBar::class) + assertThat(southComponent).isInstanceOf(javax.swing.JPanel::class) + // StatusBar must be accessible and correctly initialised + assertThat(frame.statusBar).isInstanceOf(StatusBar::class) + assertThat(frame.statusBar.isVisible).isTrue() } } @@ -224,4 +229,79 @@ class FrameTest : AbstractFrameTestBase() { assertThat(layout.getLayoutComponent(BorderLayout.SOUTH)).isNotNull() } } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("south panel has one component (StatusBar) in editing mode") + fun southPanelHasOneComponentInEditingMode() { + runOnEDT { + // Default state after Frame construction is editing mode — only StatusBar in south panel + val southPanel = + (frame.contentPane.layout as BorderLayout) + .getLayoutComponent(BorderLayout.SOUTH) as javax.swing.JPanel + assertThat(southPanel.componentCount).isEqualTo(1) + assertThat(southPanel.getComponent(0)).isInstanceOf(StatusBar::class) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("south panel gains EventTimelinePanel when switching to simulation mode") + fun southPanelGainsTimelinePanelInSimulationMode() { + val context = cz.vutbr.fit.interlockSim.testutil.createMockSimulationContext( + cz.vutbr.fit.interlockSim.testutil.TestFixtures.loadShuntingXml() + ) + runOnEDT { + frame.setContext(context) + // Simulation mode: EventTimelinePanel is added above StatusBar + val southPanel = + (frame.contentPane.layout as BorderLayout) + .getLayoutComponent(BorderLayout.SOUTH) as javax.swing.JPanel + assertThat(southPanel.componentCount).isEqualTo(2) + } + runOnEDT { frame.stopSimulation() } + context.close() + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("south panel returns to one component when switching back to editing mode") + fun southPanelRestoresOneComponentAfterSwitchingToEditingMode() { + val simContext = cz.vutbr.fit.interlockSim.testutil.createMockSimulationContext( + cz.vutbr.fit.interlockSim.testutil.TestFixtures.loadShuntingXml() + ) + val editContext = editingContextFactory.createEmptyContext() + runOnEDT { + frame.setContext(simContext) + // switch back to editing — EventTimelinePanel removed, only StatusBar remains + frame.setContext(editContext) + val southPanel = + (frame.contentPane.layout as BorderLayout) + .getLayoutComponent(BorderLayout.SOUTH) as javax.swing.JPanel + assertThat(southPanel.componentCount).isEqualTo(1) + // StatusBar must still be visible after returning to editing mode + assertThat(frame.statusBar.isVisible).isTrue() + } + simContext.close() + editContext.close() + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("setContext closes previous SimulationContext when switching to a new one") + fun setContextClosesPreviousSimulationContext() { + val context1 = cz.vutbr.fit.interlockSim.testutil.createMockSimulationContext( + cz.vutbr.fit.interlockSim.testutil.TestFixtures.loadShuntingXml() + ) + val context2 = cz.vutbr.fit.interlockSim.testutil.createMockSimulationContext( + cz.vutbr.fit.interlockSim.testutil.TestFixtures.loadShuntingXml() + ) + runOnEDT { + frame.setContext(context1) + frame.setContext(context2) + } + assertThat(context1.closeCount).isEqualTo(1) + runOnEDT { frame.stopSimulation() } + context2.close() + } } diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/MenuBarTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/MenuBarTest.kt index 644f6123b..0c99c63e1 100644 --- a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/MenuBarTest.kt +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/MenuBarTest.kt @@ -15,10 +15,13 @@ import assertk.assertThat import assertk.assertions.hasSize import assertk.assertions.isEqualTo import assertk.assertions.isNotNull +import cz.vutbr.fit.interlockSim.testutil.TestFixtures import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.assertThrows +import java.io.File import java.util.concurrent.TimeUnit import javax.swing.JMenu import javax.swing.JMenuItem @@ -53,8 +56,8 @@ class MenuBarTest : AbstractFrameTestBase() { @DisplayName("menu bar has correct number of menus") fun menuBarHasCorrectNumberOfMenus() { runOnEDT { - // Verify menu bar has 2 menus (File and Help) - assertThat(menuBar.menuCount).isEqualTo(2) + // Verify menu bar has 3 menus (File, Simulation and Help) + assertThat(menuBar.menuCount).isEqualTo(3) } } @@ -69,17 +72,139 @@ class MenuBarTest : AbstractFrameTestBase() { } } + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("menu bar has Simulation menu") + fun menuBarHasSimulationMenu() { + runOnEDT { + // Simulation is the second menu (index 1) + val simulationMenu = menuBar.getMenu(1) as JMenu + assertThat(simulationMenu.text).isEqualTo("Simulation") + } + } + @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) @DisplayName("menu bar has Help menu") fun menuBarHasHelpMenu() { runOnEDT { - // Get second menu (Help) - val helpMenu = menuBar.getMenu(1) as JMenu + // Help is now the third menu (index 2) + val helpMenu = menuBar.getMenu(2) as JMenu assertThat(helpMenu.text).isEqualTo("Help") } } + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("Simulation menu has Start action") + fun simulationMenuHasStartAction() { + runOnEDT { + val simMenu = menuBar.getMenu(1) as JMenu + + val menuItems = + (0 until simMenu.itemCount) + .map { simMenu.getItem(it) } + .filterIsInstance() + + val startItem = menuItems.find { it.text == "Start..." } + assertThat(startItem).isNotNull() + assertThat(startItem!!.isEnabled).isEqualTo(true) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("Simulation menu has Stop action") + fun simulationMenuHasStopAction() { + runOnEDT { + val simMenu = menuBar.getMenu(1) as JMenu + + val menuItems = + (0 until simMenu.itemCount) + .map { simMenu.getItem(it) } + .filterIsInstance() + + val stopItem = menuItems.find { it.text == "Stop" } + assertThat(stopItem).isNotNull() + assertThat(stopItem!!.isEnabled).isEqualTo(true) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("Simulation menu has separator") + fun simulationMenuHasSeparator() { + runOnEDT { + val simMenu = menuBar.getMenu(1) as JMenu + + val separatorCount = + (0 until simMenu.itemCount) + .count { simMenu.getItem(it) == null } + + assertThat(separatorCount).isEqualTo(1) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("Simulation menu has Speed submenu") + fun simulationMenuHasSpeedSubmenu() { + runOnEDT { + val simMenu = menuBar.getMenu(1) as JMenu + + val speedMenu = + (0 until simMenu.itemCount) + .mapNotNull { simMenu.getItem(it) } + .filterIsInstance() + .find { it.text == "Speed" } + + assertThat(speedMenu).isNotNull() + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("Speed submenu has 7 preset items") + fun speedSubmenuHas7Items() { + runOnEDT { + val simMenu = menuBar.getMenu(1) as JMenu + val speedMenu = + (0 until simMenu.itemCount) + .mapNotNull { simMenu.getItem(it) } + .filterIsInstance() + .first { it.text == "Speed" } + + val speedItems = + (0 until speedMenu.itemCount) + .mapNotNull { speedMenu.getItem(it) } + .filterIsInstance() + + assertThat(speedItems).hasSize(7) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("Speed submenu items have correct labels") + fun speedSubmenuItemsHaveCorrectLabels() { + runOnEDT { + val simMenu = menuBar.getMenu(1) as JMenu + val speedMenu = + (0 until simMenu.itemCount) + .mapNotNull { simMenu.getItem(it) } + .filterIsInstance() + .first { it.text == "Speed" } + + val labels = + (0 until speedMenu.itemCount) + .mapNotNull { speedMenu.getItem(it) } + .filterIsInstance() + .map { it.text } + + assertThat(labels).isEqualTo(listOf("0.1x", "0.5x", "1x", "2x", "5x", "10x", "50x")) + } + } + @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) @DisplayName("File menu has Save action") @@ -147,7 +272,7 @@ class MenuBarTest : AbstractFrameTestBase() { @DisplayName("Help menu has Usage action") fun helpMenuHasUsageAction() { runOnEDT { - val helpMenu = menuBar.getMenu(1) as JMenu + val helpMenu = menuBar.getMenu(2) as JMenu // Get menu items val menuItems = @@ -166,7 +291,7 @@ class MenuBarTest : AbstractFrameTestBase() { @DisplayName("Help menu has About action") fun helpMenuHasAboutAction() { runOnEDT { - val helpMenu = menuBar.getMenu(1) as JMenu + val helpMenu = menuBar.getMenu(2) as JMenu // Get menu items val menuItems = @@ -185,7 +310,7 @@ class MenuBarTest : AbstractFrameTestBase() { @DisplayName("Help menu has exactly 2 items") fun helpMenuHasExactlyTwoItems() { runOnEDT { - val helpMenu = menuBar.getMenu(1) as JMenu + val helpMenu = menuBar.getMenu(2) as JMenu // Get menu items val menuItems = @@ -228,13 +353,38 @@ class MenuBarTest : AbstractFrameTestBase() { } } + // ── loadSimulationContext ───────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("loadSimulationContext returns a SimulationContext for a valid XML file") + fun loadSimulationContextWithValidFileReturnsContext() { + val tempFile = File.createTempFile("vyhybna", ".xml").apply { + outputStream().use { out -> TestFixtures.loadShuntingXml().copyTo(out) } + deleteOnExit() + } + // Must be called off-EDT (SwingWorker's doInBackground thread) + val context = menuBar.loadSimulationContext(tempFile) + assertThat(context).isNotNull() + context.close() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("loadSimulationContext throws for a non-existent file") + fun loadSimulationContextWithMissingFileThrows() { + assertThrows { + menuBar.loadSimulationContext(File("this-file-does-not-exist-9x3f.xml")) + } + } + @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) @DisplayName("menu actions are enabled by default") fun menuActionsAreEnabledByDefault() { runOnEDT { val fileMenu = menuBar.getMenu(0) as JMenu - val helpMenu = menuBar.getMenu(1) as JMenu + val helpMenu = menuBar.getMenu(2) as JMenu // Verify File menu items are enabled val fileItems = diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControlPanelTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControlPanelTest.kt new file mode 100644 index 000000000..df2a4c923 --- /dev/null +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControlPanelTest.kt @@ -0,0 +1,434 @@ +/* + Brno University of Technology + Faculty of Information Technology + + BSc Thesis 2006/2007 + Railway Interlocking Simulator + + Unit tests for SimulationControlPanel (Issue #190) +*/ + +package cz.vutbr.fit.interlockSim.gui + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isGreaterThan +import assertk.assertions.isTrue +import cz.vutbr.fit.interlockSim.context.SimulationContext +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JSlider +import javax.swing.SwingUtilities + +/** + * Unit tests for [SimulationControlPanel]. + * + * All Swing operations are performed on the EDT via [SwingUtilities.invokeAndWait]. + * [SimulationControlPanel] is a [javax.swing.JPanel] subclass that can be created in + * headless mode (no display required). + * + * Covers: + * - Initial state: slider at 1×, label shows "1.0x", 7 preset buttons visible + * - Slider movement updates speed label and runner + * - Preset buttons set speed and update slider / label + * - Setting runner synchronises UI to runner's current speed + * - Runner property change events update the UI + * - Null runner clears wiring without throwing + * - Preset 50x moves slider to max (10x) but sets runner to 50x + */ +@DisplayName("SimulationControlPanel") +class SimulationControlPanelTest { + private lateinit var panel: SimulationControlPanel + private lateinit var context: SimulationContext + + @BeforeEach + fun setUp() { + SwingUtilities.invokeAndWait { + panel = SimulationControlPanel() + } + context = mockk(relaxed = true) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun findSlider(): JSlider = + findComponent(panel, JSlider::class.java) + ?: error("JSlider not found in SimulationControlPanel") + + private fun findSpeedLabel(): JLabel { + // The speed label is the one whose text ends with 'x' + return findAllComponents(panel, JLabel::class.java) + .firstOrNull { it.text.endsWith("x") } + ?: error("Speed label not found in SimulationControlPanel") + } + + private fun findPresetButtons(): List = + findAllComponents(panel, JButton::class.java) + + /** Recursively find the first component of [type] in the container hierarchy. */ + private fun findComponent(container: java.awt.Container, type: Class): T? { + for (c in container.components) { + if (type.isInstance(c)) { + @Suppress("UNCHECKED_CAST") + return c as T + } + if (c is java.awt.Container) { + val found = findComponent(c, type) + if (found != null) return found + } + } + return null + } + + /** Recursively collect all components of [type] in the container hierarchy. */ + private fun findAllComponents(container: java.awt.Container, type: Class): List { + val result = mutableListOf() + for (c in container.components) { + if (type.isInstance(c)) { + @Suppress("UNCHECKED_CAST") + result.add(c as T) + } + if (c is java.awt.Container) { + result.addAll(findAllComponents(c, type)) + } + } + return result + } + + // ── Initial state ───────────────────────────────────────────────────────── + + @Test + @DisplayName("initial slider value corresponds to 1.0x speed") + fun initialSliderValue() { + SwingUtilities.invokeAndWait { + val slider = findSlider() + // slider 10 → 10 / 10.0 = 1.0x + assertThat(slider.value).isEqualTo(10) + } + } + + @Test + @DisplayName("initial speed label shows 1.0x") + fun initialSpeedLabel() { + SwingUtilities.invokeAndWait { + val label = findSpeedLabel() + assertThat(label.text).isEqualTo("1.0x") + } + } + + @Test + @DisplayName("panel contains exactly 7 preset buttons") + fun sevenPresetButtons() { + SwingUtilities.invokeAndWait { + val buttons = findPresetButtons() + assertThat(buttons.size).isEqualTo(7) + } + } + + @Test + @DisplayName("slider minimum is 1 (= 0.1x)") + fun sliderMinimum() { + SwingUtilities.invokeAndWait { + assertThat(findSlider().minimum).isEqualTo(1) + } + } + + @Test + @DisplayName("slider maximum is 100 (= 10.0x)") + fun sliderMaximum() { + SwingUtilities.invokeAndWait { + assertThat(findSlider().maximum).isEqualTo(100) + } + } + + // ── Slider interaction ──────────────────────────────────────────────────── + + @Test + @DisplayName("moving slider to 50 updates speed label to 5.0x") + fun sliderUpdateLabel() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + findSlider().value = 50 + } + SwingUtilities.invokeAndWait { + assertThat(findSpeedLabel().text).isEqualTo("5.0x") + } + } + + @Test + @DisplayName("moving slider updates runner speedMultiplier") + fun sliderUpdatesRunner() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + findSlider().value = 20 // 20 / 10.0 = 2.0x + } + assertThat(runner.speedMultiplier).isEqualTo(2.0) + } + + @Test + @DisplayName("moving slider to 1 sets speed to 0.1x") + fun sliderAtMinSpeed() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + findSlider().value = 1 + } + assertThat(runner.speedMultiplier).isEqualTo(0.1) + } + + @Test + @DisplayName("moving slider to 100 sets speed to 10.0x") + fun sliderAtMaxSpeed() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + findSlider().value = 100 + } + assertThat(runner.speedMultiplier).isEqualTo(10.0) + } + + // ── Preset buttons ──────────────────────────────────────────────────────── + + @Test + @DisplayName("clicking 0.1x preset sets runner speed to 0.1") + fun presetPointOneX() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + val btn = findPresetButtons().first { it.text == "0.1x" } + btn.doClick() + } + assertThat(runner.speedMultiplier).isEqualTo(0.1) + } + + @Test + @DisplayName("clicking 1x preset sets runner speed to 1.0") + fun presetOneX() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + // First move away from default + findSlider().value = 50 + val btn = findPresetButtons().first { it.text == "1x" } + btn.doClick() + } + assertThat(runner.speedMultiplier).isEqualTo(1.0) + } + + @Test + @DisplayName("clicking 2x preset updates slider to position 20") + fun preset2xUpdatesSlider() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + val btn = findPresetButtons().first { it.text == "2x" } + btn.doClick() + } + SwingUtilities.invokeAndWait { + assertThat(findSlider().value).isEqualTo(20) + } + } + + @Test + @DisplayName("clicking 10x preset sets runner speed to 10.0") + fun preset10x() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + val btn = findPresetButtons().first { it.text == "10x" } + btn.doClick() + } + assertThat(runner.speedMultiplier).isEqualTo(10.0) + } + + @Test + @DisplayName("clicking 50x preset sets runner speed to 50.0 (above slider range)") + fun preset50xSetsRunnerSpeed() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + val btn = findPresetButtons().first { it.text == "50x" } + btn.doClick() + } + assertThat(runner.speedMultiplier).isEqualTo(50.0) + } + + @Test + @DisplayName("clicking 50x preset clamps slider to maximum (100)") + fun preset50xClampsSlider() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + val btn = findPresetButtons().first { it.text == "50x" } + btn.doClick() + } + SwingUtilities.invokeAndWait { + assertThat(findSlider().value).isEqualTo(100) + } + } + + @Test + @DisplayName("clicking preset routes through onSpeedChanged callback when wired") + fun presetRoutesViaCallbackWhenWired() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + // Simulate SimulationController.setSpeed: callback updates runner + panel.onSpeedChanged = { speed -> runner.speedMultiplier = speed } + findPresetButtons().first { it.text == "2x" }.doClick() + } + assertThat(runner.speedMultiplier).isEqualTo(2.0) + } + + @Test + @DisplayName("dragging slider routes through onSpeedChanged callback when wired") + fun sliderRoutesViaCallbackWhenWired() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + // Simulate SimulationController.setSpeed: callback updates runner + panel.onSpeedChanged = { speed -> runner.speedMultiplier = speed } + findSlider().value = 20 // 20 / 10.0 = 2.0x + } + assertThat(runner.speedMultiplier).isEqualTo(2.0) + } + + // ── Runner wiring ───────────────────────────────────────────────────────── + + @Test + @DisplayName("setting runner synchronises UI to runner's current speed") + fun settingRunnerSyncsUi() { + val runner = SimulationRunner(context) + runner.speedMultiplier = 2.0 + SwingUtilities.invokeAndWait { + panel.runner = runner + } + SwingUtilities.invokeAndWait { + assertThat(findSpeedLabel().text).isEqualTo("2.0x") + assertThat(findSlider().value).isEqualTo(20) + } + } + + @Test + @DisplayName("runner speed change fires PropertyChange and updates panel label") + fun runnerPropChangeUpdatesLabel() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + } + // Change speed on a background thread (simulates simulation thread) + runner.speedMultiplier = 5.0 + // Allow invokeLater to settle + SwingUtilities.invokeAndWait { /* flush EDT queue */ } + SwingUtilities.invokeAndWait { + assertThat(findSpeedLabel().text).isEqualTo("5.0x") + } + } + + @Test + @DisplayName("setting runner to null does not throw and panel stays at last speed") + fun settingRunnerNullNoThrow() { + val runner = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner + findSlider().value = 30 + panel.runner = null // Should not throw + } + // After null, panel retains last displayed speed + SwingUtilities.invokeAndWait { + assertThat(findSpeedLabel().text).isEqualTo("3.0x") + } + } + + @Test + @DisplayName("replacing runner removes listener from old runner") + fun replacingRunnerRemovesOldListener() { + val runner1 = SimulationRunner(context) + val runner2 = SimulationRunner(context) + SwingUtilities.invokeAndWait { + panel.runner = runner1 + panel.runner = runner2 + } + // Change speed on old runner — should NOT affect panel (listener removed) + runner1.speedMultiplier = 8.0 + SwingUtilities.invokeAndWait { /* flush */ } + SwingUtilities.invokeAndWait { + // Panel should still show runner2's default speed (1.0x), not runner1's 8.0x + assertThat(findSpeedLabel().text).isEqualTo("1.0x") + } + } + + // ── Panel visibility ────────────────────────────────────────────────────── + + @Test + @DisplayName("panel has positive preferred width") + fun panelHasSize() { + SwingUtilities.invokeAndWait { + panel.addNotify() // trigger layout + assertThat(panel.preferredSize.width).isGreaterThan(0) + } + } + + @Test + @DisplayName("panel is visible by default (visibility controlled by Frame)") + fun panelVisibleByDefault() { + SwingUtilities.invokeAndWait { + assertThat(panel.isVisible).isTrue() + } + } + + // ── Preset labels ───────────────────────────────────────────────────────── + + @Test + @DisplayName("preset button labels match expected values") + fun presetButtonLabels() { + SwingUtilities.invokeAndWait { + val labels = findPresetButtons().map { it.text } + assertThat(labels).isEqualTo(listOf("0.1x", "0.5x", "1x", "2x", "5x", "10x", "50x")) + } + } + + // ── onSpeedChanged callback ─────────────────────────────────────────────── + + @Test + @DisplayName("moving slider invokes onSpeedChanged callback with correct speed") + fun sliderInvokesOnSpeedChangedCallback() { + var capturedSpeed: Double? = null + SwingUtilities.invokeAndWait { + panel.onSpeedChanged = { speed -> capturedSpeed = speed } + findSlider().value = 30 // 30 / 10.0 = 3.0x + } + assertThat(capturedSpeed).isEqualTo(3.0) + } + + @Test + @DisplayName("clicking preset button invokes onSpeedChanged callback with correct speed") + fun presetClickInvokesOnSpeedChangedCallback() { + var capturedSpeed: Double? = null + SwingUtilities.invokeAndWait { + panel.onSpeedChanged = { speed -> capturedSpeed = speed } + findPresetButtons().first { it.text == "2x" }.doClick() + } + assertThat(capturedSpeed).isEqualTo(2.0) + } + + @Test + @DisplayName("runner speed change does not invoke onSpeedChanged callback (no feedback loop)") + fun runnerSpeedChangeDoesNotInvokeCallback() { + val runner = SimulationRunner(context) + var callbackCount = 0 + SwingUtilities.invokeAndWait { + panel.runner = runner + panel.onSpeedChanged = { callbackCount++ } + } + runner.speedMultiplier = 3.0 + SwingUtilities.invokeAndWait { /* flush invokeLater from runnerListener */ } + assertThat(callbackCount).isEqualTo(0) + } +} diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControllerBridgeIntegrationTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControllerBridgeIntegrationTest.kt new file mode 100644 index 000000000..eca8b83d4 --- /dev/null +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControllerBridgeIntegrationTest.kt @@ -0,0 +1,133 @@ +/* + Brno University of Technology + Faculty of Information Technology + + BSc Thesis 2006/2007 + Railway Interlocking Simulator + + Bridge integration test (Goal 7 / Issue #187): with real DefaultSimulationContext + and a real ShuntingLoop, SimulationController.setSpeed must reach the live + ShuntingLoop instance through DefaultSimulationContext.getMainProcess() and the + SpeedControllable cast — closing the loop the user observed broken in + `exampleGui shuntingLoop`. +*/ + +package cz.vutbr.fit.interlockSim.gui + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isSameAs +import assertk.assertions.isTrue +import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext +import cz.vutbr.fit.interlockSim.context.SimulationContextFactory +import cz.vutbr.fit.interlockSim.sim.ShuntingLoop +import cz.vutbr.fit.interlockSim.sim.SpeedControllable +import cz.vutbr.fit.interlockSim.testutil.KoinTestBase +import cz.vutbr.fit.interlockSim.testutil.TestFixtures +import cz.vutbr.fit.interlockSim.testutil.integrationTestModule +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.koin.core.module.Module +import org.koin.test.get +import java.util.concurrent.TimeUnit + +@Tag("integration-test") +@DisplayName("SimulationController -> ShuntingLoop bridge (real types)") +class SimulationControllerBridgeIntegrationTest : KoinTestBase() { + + override fun getTestModule(): Module = integrationTestModule + + @Test + @DisplayName("Real ShuntingLoop is recognised as SpeedControllable via getMainProcess()") + fun realShuntingLoopIsRecognizedAsSpeedControllable() { + val factory = get() + val ctx = TestFixtures.loadShuntingXml().use { factory.createContext(it) as DefaultSimulationContext } + + ctx.use { context -> + context.getInOuts() + val loop = ShuntingLoop(context, 60L, enableRealTimeSync = true) + context.setMainProcess(loop) + + val mainProcess = context.getMainProcess() + assertThat(mainProcess).isSameAs(loop) + assertThat(mainProcess is SpeedControllable).isTrue() + + val controllable = mainProcess as SpeedControllable + controllable.speedMultiplier = 2.5 + // Same instance — RealTimeSynch on the simulation thread reads through + // the same @Volatile-backed field on its next iteration. + assertThat(loop.speedMultiplier).isEqualTo(2.5) + } + } + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("controller.setSpeed propagates to live ShuntingLoop while simulation runs") + fun controllerSetSpeedPropagatesToLiveShuntingLoop() { + val factory = get() + val ctx = TestFixtures.loadShuntingXml().use { factory.createContext(it) as DefaultSimulationContext } + + ctx.use { context -> + context.getInOuts() + // Long simulated end time + real-time sync so the simulation thread is + // active when we change speed. We stop() it explicitly in finally. + val loop = ShuntingLoop( + context, + endTime = 600L, + enableRealTimeSync = true, + initialSpeedMultiplier = 1.0 + ) + context.setMainProcess(loop) + + val controller = SimulationController() + try { + controller.start(context) + + // SimulationController wires its `speedControllable` reference + // synchronously inside start(), before launching the simulation thread, + // so the bridge is live by the time we get here. + controller.setSpeed(2.0) + assertThat(loop.speedMultiplier).isEqualTo(2.0) + + controller.setSpeed(0.5) + assertThat(loop.speedMultiplier).isEqualTo(0.5) + + controller.setSpeed(10.0) + assertThat(loop.speedMultiplier).isEqualTo(10.0) + } finally { + controller.stop() + } + } + } + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("setSpeed before start applies on start() to the real ShuntingLoop") + fun preStartSpeedAppliedToRealShuntingLoopOnStart() { + val factory = get() + val ctx = TestFixtures.loadShuntingXml().use { factory.createContext(it) as DefaultSimulationContext } + + ctx.use { context -> + context.getInOuts() + val loop = ShuntingLoop( + context, + endTime = 600L, + enableRealTimeSync = true, + initialSpeedMultiplier = 1.0 + ) + context.setMainProcess(loop) + + val controller = SimulationController() + try { + controller.setSpeed(3.0) + controller.start(context) + + assertThat(loop.speedMultiplier).isEqualTo(3.0) + } finally { + controller.stop() + } + } + } +} diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControllerSpeedPropagationTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControllerSpeedPropagationTest.kt new file mode 100644 index 000000000..1b72d1cef --- /dev/null +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControllerSpeedPropagationTest.kt @@ -0,0 +1,174 @@ +/* + Brno University of Technology + Faculty of Information Technology + + BSc Thesis 2006/2007 + Railway Interlocking Simulator + + Tests for SimulationController -> SpeedControllable bridge (Goal 7 / Issue #187): + verifies that speed-control-button clicks (which land in setSpeed) propagate + into the running simulation's main process, not just SimulationRunner — the + latter alone has no observable effect because SimulationRunner.throttle() is + not wired into the simulation loop. +*/ + +package cz.vutbr.fit.interlockSim.gui + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue +import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext +import cz.vutbr.fit.interlockSim.sim.LoopProcess +import cz.vutbr.fit.interlockSim.sim.SpeedControllable +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Verifies the missing link the rest of Goal 7's plumbing did not establish: + * `SimulationController.setSpeed` must reach the running main process when it + * implements [SpeedControllable], not just the bookkeeping field on + * [SimulationRunner]. Without this, speed buttons are dead in `exampleGui` mode. + * + * ## Synchronisation design + * Each test gets its own [startedLatch] and [blockSim] latches (instance fields, + * not companion-object shared state). [blockSim] is counted down in [tearDown] + * even if the test fails early, so the sim thread always unblocks and daemon threads + * do not linger across tests. + * + * The 30-second timeouts accommodate busy CI runners where thread scheduling can be + * delayed by several seconds (CI uses `maxParallelForks = availableProcessors()` + * with multiple JVM forks competing for CPU). + */ +@DisplayName("SimulationController -> SpeedControllable propagation") +class SimulationControllerSpeedPropagationTest { + /** A LoopProcess that exposes a mutable speed multiplier for assertion. */ + private class FakeSpeedyMainProcess : LoopProcess(), SpeedControllable { + @Volatile + override var speedMultiplier: Double = 1.0 + + override suspend fun iteration() { + // never actually scheduled in these tests — context.run() is stubbed + } + } + + /** A LoopProcess that does NOT implement SpeedControllable, to prove the cast is safe. */ + private class PlainMainProcess : LoopProcess() { + override suspend fun iteration() = Unit + } + + // ── Per-test latches (instance fields, NOT companion-object shared state) ─── + // JUnit 5 creates a fresh test instance per method, so these are naturally + // isolated. tearDown() guarantees blockSim is released even on test failure, + // preventing lingering sim threads from other tests. + private lateinit var startedLatch: CountDownLatch + private lateinit var blockSim: CountDownLatch + + @BeforeEach + fun setUp() { + startedLatch = CountDownLatch(1) + blockSim = CountDownLatch(1) + } + + @AfterEach + fun tearDown() { + // Guarantee the sim thread unblocks even if the test fails before releaseSim(). + blockSim.countDown() + unmockkAll() + } + + private fun newController(): SimulationController = SimulationController() + + private fun mockContext(mainProcess: LoopProcess?): DefaultSimulationContext = + mockk(relaxed = true).also { ctx -> + every { ctx.getMainProcess() } returns mainProcess + every { ctx.run() } answers { + startedLatch.countDown() + blockSim.await(30, TimeUnit.SECONDS) + } + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + @DisplayName("setSpeed propagates to a SpeedControllable main process while running") + fun setSpeedReachesSpeedControllable() { + val process = FakeSpeedyMainProcess() + val ctx = mockContext(process) + val controller = newController() + + controller.start(ctx) + assertThat(startedLatch.await(30, TimeUnit.SECONDS)).isTrue() + + controller.setSpeed(2.5) + + assertThat(process.speedMultiplier).isEqualTo(2.5) + assertThat(controller.runner!!.speedMultiplier).isEqualTo(2.5) + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + @DisplayName("setSpeed before start applies on next start (desiredSpeed propagation)") + fun preStartSpeedAppliedOnStart() { + val process = FakeSpeedyMainProcess() + val ctx = mockContext(process) + val controller = newController() + + controller.setSpeed(0.5) + controller.start(ctx) + assertThat(startedLatch.await(30, TimeUnit.SECONDS)).isTrue() + + assertThat(process.speedMultiplier).isEqualTo(0.5) + assertThat(controller.runner!!.speedMultiplier).isEqualTo(0.5) + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + @DisplayName("setSpeed is a safe no-op when main process is not SpeedControllable") + fun nonControllableMainProcessNoOp() { + val ctx = mockContext(PlainMainProcess()) + val controller = newController() + + controller.start(ctx) + assertThat(startedLatch.await(30, TimeUnit.SECONDS)).isTrue() + + controller.setSpeed(3.0) // must not throw, must not crash on the cast + assertThat(controller.runner!!.speedMultiplier).isEqualTo(3.0) + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + @DisplayName("setSpeed is a safe no-op when main process is null") + fun nullMainProcessNoOp() { + val ctx = mockContext(null) + val controller = newController() + + controller.start(ctx) + // Wait for the sim thread to actually start and block on blockSim.await() + // before we call setSpeed() and assert on runner.speedMultiplier. + // On a busy CI runner, thread scheduling can delay the sim thread by + // several seconds — without this await the test is non-deterministic. + assertThat(startedLatch.await(30, TimeUnit.SECONDS)).isTrue() + + controller.setSpeed(1.5) + assertThat(controller.runner!!.speedMultiplier).isEqualTo(1.5) + + blockSim.countDown() + controller.stop() + } +} diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControllerTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControllerTest.kt new file mode 100644 index 000000000..69b8f8aad --- /dev/null +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControllerTest.kt @@ -0,0 +1,768 @@ +/* + Brno University of Technology + Faculty of Information Technology + + BSc Thesis 2006/2007 + Railway Interlocking Simulator + + Unit tests for SimulationController (Issue #189) +*/ + +package cz.vutbr.fit.interlockSim.gui + +import assertk.assertThat +import assertk.assertions.doesNotContain +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import cz.vutbr.fit.interlockSim.context.SimulationContext +import cz.vutbr.fit.interlockSim.gui.animation.ControlPanel +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.SwingUtilities + +/** + * Unit tests for [SimulationController]. + * + * [SimulationController] encapsulates all simulation lifecycle logic extracted from + * [Frame], enabling it to be tested headlessly (no X11 display required). [ControlPanel] + * is a [javax.swing.JPanel] that can be created in headless mode. + * + * Covers: + * - [SimulationController.start] creates and starts a [SimulationRunner] + * - [SimulationController.start] enables the Stop button and updates status to "Running" + * - [SimulationController.start] is idempotent while runner is alive + * - [SimulationController.stop] interrupts the runner and disables the Stop button + * - [SimulationController.stop] is a no-op when nothing is running + * - Monitor thread invokes [onCompleted] callback when simulation finishes naturally + * - Monitor thread updates ControlPanel to "Stopped" after natural completion + * - [SimulationController.isRunning] reflects the runner state correctly + * - [SimulationController.runner] field is null before start and after stop + * - Race condition fix: [SimulationRunner.start] is called before the monitor thread + * - Stale-monitor fix: stop+start does not let old monitor clobber new run's panel state + */ +@DisplayName("SimulationController") +class SimulationControllerTest { + private lateinit var controlPanel: ControlPanel + private lateinit var context: SimulationContext + private lateinit var context2: SimulationContext + + @BeforeEach + fun setUp() { + // ControlPanel is a JPanel subclass; creating it requires the EDT in strict Swing + // environments. mockk does not require EDT. + SwingUtilities.invokeAndWait { + controlPanel = ControlPanel() + } + context = mockk(relaxed = true) + context2 = mockk(relaxed = true) + } + + // ── start: Stop button ──────────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("start enables stop button while simulation is running") + fun startEnablesStopButton() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + SwingUtilities.invokeAndWait { + assertThat(findStopButton()!!.isEnabled).isTrue() + } + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("stop disables stop button") + fun stopDisablesStopButton() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + controller.stop() + blockSim.countDown() + + SwingUtilities.invokeAndWait { + assertThat(findStopButton()!!.isEnabled).isFalse() + } + } + + // ── start: status label ─────────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("start sets status label to Running") + fun startSetsStatusRunning() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + SwingUtilities.invokeAndWait { + assertThat(findStatusLabel()!!.text).isEqualTo("Status: Running") + } + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("stop sets status label to Stopped") + fun stopSetsStatusStopped() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + controller.stop() + blockSim.countDown() + + SwingUtilities.invokeAndWait { + assertThat(findStatusLabel()!!.text).isEqualTo("Status: Stopped") + } + } + + // ── isRunning ───────────────────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("isRunning returns true while simulation thread is alive") + fun isRunningTrueWhileSimRunning() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + assertThat(controller.isRunning()).isTrue() + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("isRunning returns false before start is called") + fun isRunningFalseBeforeStart() { + val controller = createController() + assertThat(controller.isRunning()).isFalse() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("isRunning returns false after stop") + fun isRunningFalseAfterStop() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + controller.stop() + blockSim.countDown() + + assertThat(controller.isRunning()).isFalse() + } + + // ── stop no-op ──────────────────────────────────────────────────────────── + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("stop is a no-op when nothing is running") + fun stopNoOpWhenNotRunning() { + val controller = createController() + controller.stop() // must not throw + controller.stop() + assertThat(controller.isRunning()).isFalse() + } + + // ── idempotent start ────────────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("start is idempotent — second call while running is a no-op") + fun startIdempotent() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + var runCount = 0 + every { context.run() } answers { + runCount++ + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + controller.start(context) + controller.start(context) + + blockSim.countDown() + controller.stop() + + assertThat(runCount).isEqualTo(1) + } + + // ── runner field ────────────────────────────────────────────────────────── + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("runner is null before start") + fun runnerNullBeforeStart() { + val controller = createController() + assertThat(controller.runner).isNull() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("runner is non-null while running") + fun runnerNonNullWhileRunning() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(controller.runner).isNotNull() + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("runner is null after stop") + fun runnerNullAfterStop() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + controller.stop() + blockSim.countDown() + + assertThat(controller.runner).isNull() + } + + // ── onCompleted callback ────────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("onCompleted is invoked when simulation finishes naturally") + fun onCompletedInvokedOnNaturalFinish() { + val completedLatch = CountDownLatch(1) + every { context.run() } answers { /* returns immediately */ } + + val controller = createController(onCompleted = { completedLatch.countDown() }) + controller.start(context) + + assertThat(completedLatch.await(5, TimeUnit.SECONDS)).isTrue() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("onCompleted resets ControlPanel status to Stopped after natural finish") + fun onCompletedResetsPanelOnNaturalFinish() { + val completedLatch = CountDownLatch(1) + every { context.run() } answers { /* returns immediately */ } + + val controller = createController(onCompleted = { completedLatch.countDown() }) + controller.start(context) + + assertThat(completedLatch.await(5, TimeUnit.SECONDS)).isTrue() + SwingUtilities.invokeAndWait { /* flush EDT */ } + + SwingUtilities.invokeAndWait { + assertThat(findStopButton()!!.isEnabled).isFalse() + assertThat(findStatusLabel()!!.text).isEqualTo("Status: Stopped") + } + } + + // ── context.run() is called ─────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("start invokes context.run()") + fun startInvokesContextRun() { + every { context.run() } answers { /* returns immediately */ } + + val completedLatch = CountDownLatch(1) + val controller = createController(onCompleted = { completedLatch.countDown() }) + controller.start(context) + + assertThat(completedLatch.await(5, TimeUnit.SECONDS)).isTrue() + verify { context.run() } + } + + // ── race condition fix: runner.start() called before monitor thread ──────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("stop after start always interrupts the simulation (no race condition)") + fun stopAlwaysInterruptsAfterStart() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + + // Wait for simulation to actually start running. + // Race condition being tested: in the old design, runner.start() was called + // inside the monitor thread. If stop() was invoked before the monitor thread + // ran, stop() would interrupt nothing and the simulation would still start + // afterwards. The fix: runner.start() is called synchronously in start() + // before the monitor thread is launched, so stop() always finds a live thread. + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + // stop() must always see a running thread (runner.start() was called synchronously) + controller.stop() + blockSim.countDown() + + assertThat(controller.isRunning()).isFalse() + } + + // ── stale-monitor fix: stop+start doesn't let old monitor clobber new run ── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("start-stop-start: old monitor does not clobber new run panel state") + fun startStopStartStalemonitorSafe() { + // First run: blocks until explicitly released + val run1Started = CountDownLatch(1) + val run1Block = CountDownLatch(1) + every { context.run() } answers { + run1Started.countDown() + run1Block.await(10, TimeUnit.SECONDS) + } + + // Second run: also blocks so we can assert on panel state while it is still running + val run2Started = CountDownLatch(1) + val run2Block = CountDownLatch(1) + every { context2.run() } answers { + run2Started.countDown() + run2Block.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + + // Phase 1: start first simulation + controller.start(context) + assertThat(run1Started.await(5, TimeUnit.SECONDS)).isTrue() + SwingUtilities.invokeAndWait { + assertThat(findStatusLabel()!!.text).isEqualTo("Status: Running") + } + + // Phase 2: stop first simulation, then immediately start second simulation. + // The old monitor is still alive (run1Block not yet released). + controller.stop() + controller.start(context2) + assertThat(run2Started.await(5, TimeUnit.SECONDS)).isTrue() + + // Phase 3: release first run's block — old monitor wakes up and fires invokeLater + run1Block.countDown() + + // Give old monitor's invokeLater time to land on EDT (three flushes for safety) + flushEDT(times = 3) + + // Panel must still show Running for the new run — old monitor must not clobber it + SwingUtilities.invokeAndWait { + assertThat(findStatusLabel()!!.text).isEqualTo("Status: Running") + assertThat(findStopButton()!!.isEnabled).isTrue() + } + + // Cleanup + run2Block.countDown() + controller.stop() + } + + // ── setSpeed ────────────────────────────────────────────────────────────── + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("setSpeed rejects value below MIN_SPEED") + fun setSpeedBelowMinThrows() { + val controller = createController() + org.junit.jupiter.api.assertThrows { + controller.setSpeed(SimulationRunner.MIN_SPEED - 0.001) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("setSpeed rejects value above MAX_SPEED") + fun setSpeedAboveMaxThrows() { + val controller = createController() + org.junit.jupiter.api.assertThrows { + controller.setSpeed(SimulationRunner.MAX_SPEED + 0.001) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("setSpeed accepts boundary values MIN_SPEED and MAX_SPEED") + fun setSpeedAcceptsBoundaryValues() { + val controller = createController() + controller.setSpeed(SimulationRunner.MIN_SPEED) + controller.setSpeed(SimulationRunner.MAX_SPEED) + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("setSpeed applies immediately to an active runner") + fun setSpeedAppliedImmediatelyToActiveRunner() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + controller.setSpeed(2.0) + assertThat(controller.runner!!.speedMultiplier).isEqualTo(2.0) + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("setSpeed before start is carried into the next start()") + fun setSpeedCarriedIntoNextStart() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val controller = createController() + controller.setSpeed(0.5) // pre-select before start + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + assertThat(controller.runner!!.speedMultiplier).isEqualTo(0.5) + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("speed selection survives natural completion and is applied to the next start()") + fun speedSelectionPersistsThroughNaturalCompletion() { + val firstCompleted = CountDownLatch(1) + every { context.run() } answers { /* returns immediately — natural completion */ } + + val controller = createController(onCompleted = { firstCompleted.countDown() }) + controller.setSpeed(2.0) + controller.start(context) + + // Wait for the first simulation to complete naturally + assertThat(firstCompleted.await(5, TimeUnit.SECONDS)).isTrue() + + // Now start a second simulation (blocking so we can inspect runner speed) + val started = CountDownLatch(1) + val blockSecondSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSecondSim.await(10, TimeUnit.SECONDS) + } + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + assertThat(controller.runner!!.speedMultiplier).isEqualTo(2.0) + + blockSecondSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("preselected speed is propagated to StatusBar when start() begins") + fun preselectedSpeedPropagatesOnStart() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val statusBar = StatusBar() + val controller = createController(statusBar = statusBar) + controller.setSpeed(2.5) + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + flushEDT() + + SwingUtilities.invokeAndWait { + assertThat(statusBar.speedIndicatorText()).isEqualTo("Speed: 2.5x") + assertThat(statusBar.isSpeedIndicatorVisible()).isTrue() + } + + blockSim.countDown() + controller.stop() + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + // ── toolBar / statusBar wiring ──────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("speed change on runner propagates to StatusBar speed indicator") + fun speedChangePropagatesToStatusBar() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val statusBar = StatusBar() + + val controller = createController(statusBar = statusBar) + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + // Change speed via runner — this fires PROP_SPEED_MULTIPLIER + controller.runner!!.speedMultiplier = 2.0 + + // Flush EDT twice (listener calls invokeLater when not on EDT) + flushEDT() + + SwingUtilities.invokeAndWait { + assertThat(statusBar.speedIndicatorText()).isEqualTo("Speed: 2.0x") + assertThat(statusBar.isSpeedIndicatorVisible()).isTrue() + // Status message text must NOT be overwritten by the speed indicator + assertThat(statusBar.text).doesNotContain("Speed:") + } + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("stop resets StatusBar speed indicator to hidden") + fun stopResetsStatusBarSpeedIndicator() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val statusBar = StatusBar() + + val controller = createController(statusBar = statusBar) + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + // Set non-default speed, then stop + controller.runner!!.speedMultiplier = 3.0 + flushEDT() + + // stop() is called from test thread (not EDT), so invokeLater is used + controller.stop() + blockSim.countDown() + flushEDT() + + SwingUtilities.invokeAndWait { + assertThat(statusBar.isSpeedIndicatorVisible()).isFalse() + assertThat(statusBar.speedIndicatorText()).isEqualTo("") + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("start shows simulation controls in ToolBar") + fun startShowsToolBarSimulationControls() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val toolBar = mockk(relaxed = true) + val controller = createController(toolBar = toolBar) + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + SwingUtilities.invokeAndWait { + verify(exactly = 1) { toolBar.showSimulationControls() } + } + + blockSim.countDown() + controller.stop() + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + @DisplayName("stop hides simulation controls in ToolBar") + fun stopHidesToolBarSimulationControls() { + val started = CountDownLatch(1) + val blockSim = CountDownLatch(1) + every { context.run() } answers { + started.countDown() + blockSim.await(10, TimeUnit.SECONDS) + } + + val toolBar = mockk(relaxed = true) + val controller = createController(toolBar = toolBar) + controller.start(context) + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue() + + controller.stop() + blockSim.countDown() + flushEDT() + + SwingUtilities.invokeAndWait { + verify(exactly = 1) { toolBar.hideSimulationControls() } + } + } + + @Test + @DisplayName("poll interval is responsive (≤ 200 ms)") + fun pollIntervalIsResponsive() { + assertThat(SimulationController.SIMULATION_POLL_INTERVAL_MS <= 200L).isTrue() + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + /** + * Flushes the EDT queue by calling [SwingUtilities.invokeAndWait] the given number of times. + * + * Two flushes are typically needed when a background thread fires an event handled by + * [SwingUtilities.invokeLater]: the first flush dispatches the invokeLater task, and the + * second flush ensures any nested EDT work queued by the task is also completed. + */ + private fun flushEDT(times: Int = 2) { + repeat(times) { SwingUtilities.invokeAndWait { /* flush */ } } + } + + private fun createController( + toolBar: ToolBar? = null, + statusBar: StatusBar? = null, + onCompleted: () -> Unit = {}, + ): SimulationController = + SimulationController( + onStateChanged = { state -> + runOnEdtSync { + when (state) { + SimulationController.SimulationStatus.RUNNING -> { + toolBar?.showSimulationControls() + controlPanel.updateStatus(ControlPanel.SimulationStatus.RUNNING) + controlPanel.setStopEnabled(true) + } + + SimulationController.SimulationStatus.STOPPED -> { + toolBar?.hideSimulationControls() + controlPanel.setStopEnabled(false) + controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED) + } + } + } + }, + onSpeedChanged = { speed -> + runOnEdtSync { statusBar?.updateSpeedIndicator(speed) } + }, + onCompleted = onCompleted + ) + + /** + * Execute [action] on EDT synchronously for deterministic assertions in unit tests. + * + * Unlike the production callback wiring pattern (which dispatches asynchronously), + * this helper blocks until EDT work completes. That makes follow-up assertions + * observe final state without timing races. + */ + private fun runOnEdtSync(action: () -> Unit) { + if (SwingUtilities.isEventDispatchThread()) { + action() + } else { + SwingUtilities.invokeAndWait(action) + } + } + + private fun findStopButton(): JButton? = + (0 until controlPanel.componentCount) + .mapNotNull { controlPanel.getComponent(it) as? JButton } + .firstOrNull { it.text == "Stop" } + + private fun findStatusLabel(): JLabel? = + (0 until controlPanel.componentCount) + .mapNotNull { controlPanel.getComponent(it) as? JLabel } + .firstOrNull { it.text.startsWith("Status:") } +} diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationKeyBindingsTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationKeyBindingsTest.kt new file mode 100644 index 000000000..04eeb0b62 --- /dev/null +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationKeyBindingsTest.kt @@ -0,0 +1,419 @@ +package cz.vutbr.fit.interlockSim.gui + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import cz.vutbr.fit.interlockSim.context.SimulationContext +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.KeyStroke +import javax.swing.SwingUtilities +import kotlin.math.abs + +/** + * Unit tests for [SimulationKeyBindings] (Phase 3.1, Issue #193). + * + * Tests verify: + * - Preset key bindings (1-5 → speed presets) + * - Incremental speed adjustment (+/- keys) + * - Pause/resume toggle (Space bar) + * - Install/uninstall lifecycle + * - Edge cases (no runner, invalid speeds) + * + * These tests use lightweight Swing components (JPanel/InputMap/ActionMap) and + * [SimulationController] which is designed to be testable without a display. + */ +class SimulationKeyBindingsTest { + private lateinit var simulationContext: SimulationContext + private lateinit var simulationController: SimulationController + private lateinit var keyBindings: SimulationKeyBindings + private lateinit var rootPane: JPanel + private lateinit var blockSim: CountDownLatch + + @BeforeEach + fun setUp() { + // Block context.run() with a latch to prevent the runner from exiting immediately, + // avoiding race conditions where the monitor thread nulls out runner during tests. + blockSim = CountDownLatch(1) + + // Create mocked simulation context that blocks until tearDown + simulationContext = mockk(relaxed = true) + every { simulationContext.run() } answers { + blockSim.await(10, TimeUnit.SECONDS) + } + + // Create controller and key bindings on EDT (Swing components require EDT) + SwingUtilities.invokeAndWait { + simulationController = SimulationController() + keyBindings = SimulationKeyBindings(simulationController) + rootPane = JPanel() + } + } + + @AfterEach + fun tearDown() { + // Unblock any running simulation and stop the controller + blockSim.countDown() + simulationController.stop() + } + + // ── Installation/Uninstallation ──────────────────────────────────────────── + + @Test + fun `install creates key bindings in InputMap and ActionMap`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + + val inputMap = rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) + val actionMap = rootPane.actionMap + + // Verify preset bindings (keys 1-5 → 0.5×, 1×, 2×, 5×, 10×) + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_1, 0)]).isEqualTo("speed_preset_0.5") + assertThat(actionMap["speed_preset_0.5"]).isNotNull() + + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_2, 0)]).isEqualTo("speed_preset_1.0") + assertThat(actionMap["speed_preset_1.0"]).isNotNull() + + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_3, 0)]).isEqualTo("speed_preset_2.0") + assertThat(actionMap["speed_preset_2.0"]).isNotNull() + + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_4, 0)]).isEqualTo("speed_preset_5.0") + assertThat(actionMap["speed_preset_5.0"]).isNotNull() + + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_5, 0)]).isEqualTo("speed_preset_10.0") + assertThat(actionMap["speed_preset_10.0"]).isNotNull() + + // Verify incremental speed bindings (+/- with Shift and numpad) + val shiftEqualsStroke = KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK) + assertThat(inputMap[shiftEqualsStroke]).isEqualTo("simulation_speed_up") + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_ADD, 0)]).isEqualTo("simulation_speed_up") + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, 0)]).isEqualTo("simulation_speed_down") + val subtractStroke = KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, 0) + assertThat(inputMap[subtractStroke]).isEqualTo("simulation_speed_down") + assertThat(actionMap["simulation_speed_up"]).isNotNull() + assertThat(actionMap["simulation_speed_down"]).isNotNull() + + // Verify pause toggle binding (Space) + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0)]).isEqualTo("simulation_pause_toggle") + assertThat(actionMap["simulation_pause_toggle"]).isNotNull() + } + } + + @Test + fun `uninstall removes all key bindings`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + keyBindings.uninstall(rootPane) + + val inputMap = rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) + val actionMap = rootPane.actionMap + + // Verify all bindings are removed + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_1, 0)]).isNull() + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_2, 0)]).isNull() + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_3, 0)]).isNull() + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_4, 0)]).isNull() + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_5, 0)]).isNull() + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK)]).isNull() + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_ADD, 0)]).isNull() + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, 0)]).isNull() + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, 0)]).isNull() + assertThat(inputMap[KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0)]).isNull() + + assertThat(actionMap["speed_preset_0.5"]).isNull() + assertThat(actionMap["speed_preset_1.0"]).isNull() + assertThat(actionMap["speed_preset_2.0"]).isNull() + assertThat(actionMap["speed_preset_5.0"]).isNull() + assertThat(actionMap["speed_preset_10.0"]).isNull() + assertThat(actionMap["simulation_speed_up"]).isNull() + assertThat(actionMap["simulation_speed_down"]).isNull() + assertThat(actionMap["simulation_pause_toggle"]).isNull() + } + } + + @Test + fun `uninstall is safe when not installed`() { + SwingUtilities.invokeAndWait { + // Should not throw + keyBindings.uninstall(rootPane) + } + } + + // ── Speed Preset Actions ─────────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `key 1 sets speed to 0_5x`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + + triggerAction("speed_preset_0.5") + + SwingUtilities.invokeAndWait { + assertThat(abs(simulationController.runner!!.speedMultiplier - 0.5) < 0.01).isTrue() + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `key 2 sets speed to 1x`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + + triggerAction("speed_preset_1.0") + + SwingUtilities.invokeAndWait { + assertThat(abs(simulationController.runner!!.speedMultiplier - 1.0) < 0.01).isTrue() + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `key 3 sets speed to 2x`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + + triggerAction("speed_preset_2.0") + + SwingUtilities.invokeAndWait { + assertThat(abs(simulationController.runner!!.speedMultiplier - 2.0) < 0.01).isTrue() + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `key 4 sets speed to 5x`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + + triggerAction("speed_preset_5.0") + + SwingUtilities.invokeAndWait { + assertThat(abs(simulationController.runner!!.speedMultiplier - 5.0) < 0.01).isTrue() + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `key 5 sets speed to 10x`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + + triggerAction("speed_preset_10.0") + + SwingUtilities.invokeAndWait { + assertThat(abs(simulationController.runner!!.speedMultiplier - 10.0) < 0.01).isTrue() + } + } + + @Test + fun `preset key updates desiredSpeed even when no simulation is running`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + // Do NOT start simulation + + // Should not throw; updates desiredSpeed for next start + triggerAction("speed_preset_1.0") + + SwingUtilities.invokeAndWait { + assertThat(simulationController.runner).isNull() + } + } + + // ── Incremental Speed Adjustment ─────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `plus key increases speed by 1_5x`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + simulationController.setSpeed(2.0) // Initial speed + + triggerAction("simulation_speed_up") + + SwingUtilities.invokeAndWait { + // 2.0 × 1.5 = 3.0 + assertThat(abs(simulationController.runner!!.speedMultiplier - 3.0) < 0.01).isTrue() + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `minus key decreases speed by dividing by 1_5`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + simulationController.setSpeed(3.0) // Initial speed + + triggerAction("simulation_speed_down") + + SwingUtilities.invokeAndWait { + // 3.0 ÷ 1.5 = 2.0 + assertThat(abs(simulationController.runner!!.speedMultiplier - 2.0) < 0.01).isTrue() + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `incremental speed is clamped to MIN_SPEED`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + simulationController.setSpeed(SimulationRunner.MIN_SPEED) + + triggerAction("simulation_speed_down") + + SwingUtilities.invokeAndWait { + // Should remain at MIN_SPEED (0.1), not go below + assertThat(abs(simulationController.runner!!.speedMultiplier - SimulationRunner.MIN_SPEED) < 0.01).isTrue() + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `incremental speed is clamped to MAX_SPEED`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + simulationController.setSpeed(SimulationRunner.MAX_SPEED) + + triggerAction("simulation_speed_up") + + SwingUtilities.invokeAndWait { + // Should remain at MAX_SPEED (100.0), not go above + assertThat(abs(simulationController.runner!!.speedMultiplier - SimulationRunner.MAX_SPEED) < 0.01).isTrue() + } + } + + @Test + fun `incremental adjustment is safe when no simulation is running`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + // Do NOT start simulation + + // Should not throw + triggerAction("simulation_speed_up") + + SwingUtilities.invokeAndWait { + assertThat(simulationController.runner).isNull() + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `increment action updates desiredSpeed when no simulation is running`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + // No simulation running — default desiredSpeed is DEFAULT_SPEED (1.0) + // Speed-up multiplier is 1.5, so desiredSpeed should become 1.5 + + triggerAction("simulation_speed_up") + + // Now start a simulation and verify the runner adopts the incremented speed + simulationController.start(simulationContext) + + SwingUtilities.invokeAndWait { + assertThat(abs(simulationController.runner!!.speedMultiplier - 1.5) < 0.01).isTrue() + } + } + + // ── Pause/Resume Toggle ───────────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `space bar pauses running simulation`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + + SwingUtilities.invokeAndWait { + assertThat(simulationController.runner!!.isPaused).isFalse() + } + + triggerAction("simulation_pause_toggle") + + SwingUtilities.invokeAndWait { + assertThat(simulationController.runner!!.isPaused).isTrue() + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + fun `space bar resumes paused simulation`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + simulationController.start(simulationContext) + + SwingUtilities.invokeAndWait { + simulationController.runner!!.isPaused = true + assertThat(simulationController.runner!!.isPaused).isTrue() + } + + triggerAction("simulation_pause_toggle") + + SwingUtilities.invokeAndWait { + assertThat(simulationController.runner!!.isPaused).isFalse() + } + } + + @Test + fun `pause toggle is ignored when no simulation is running`() { + SwingUtilities.invokeAndWait { + keyBindings.install(rootPane) + } + // Do NOT start simulation + + // Should not throw + triggerAction("simulation_pause_toggle") + + SwingUtilities.invokeAndWait { + assertThat(simulationController.runner).isNull() + } + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + /** + * Trigger an action by its action key on the EDT (simulates user pressing the key). + */ + private fun triggerAction(actionKey: String) { + SwingUtilities.invokeAndWait { + val action = rootPane.actionMap[actionKey] + assertThat(action).isNotNull() + action.actionPerformed(ActionEvent(rootPane, ActionEvent.ACTION_PERFORMED, actionKey)) + } + } +} diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationSpeedGoldenTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationSpeedGoldenTest.kt new file mode 100644 index 000000000..fef89ab0e --- /dev/null +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationSpeedGoldenTest.kt @@ -0,0 +1,567 @@ +/* + Brno University of Technology + Faculty of Information Technology + + BSc Thesis 2006/2007 + Railway Interlocking Simulator + + Phase 4.1 — Golden Output Tests: Speed Control Semantics (Issue #490) +*/ + +package cz.vutbr.fit.interlockSim.gui + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isGreaterThanOrEqualTo +import assertk.assertions.isLessThanOrEqualTo +import assertk.assertions.isNotEmpty +import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext +import cz.vutbr.fit.interlockSim.context.SimulationContext.ReportType +import cz.vutbr.fit.interlockSim.context.SimulationContextFactory +import cz.vutbr.fit.interlockSim.context.navigation.PathReservationRegistry +import cz.vutbr.fit.interlockSim.objects.core.ContextPropertyChangeListener +import cz.vutbr.fit.interlockSim.sim.ShuntingLoop +import cz.vutbr.fit.interlockSim.sim.SimulationEvent +import cz.vutbr.fit.interlockSim.testutil.KoinTestBase +import cz.vutbr.fit.interlockSim.testutil.TestFixtures +import cz.vutbr.fit.interlockSim.testutil.integrationTestModule +import io.github.oshai.kotlinlogging.KotlinLogging +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.koin.core.module.Module +import org.koin.test.get +import java.util.concurrent.TimeUnit +import kotlin.math.abs + +private val logger = KotlinLogging.logger {} + +/** + * Phase 4.1 — Golden Output Tests: Speed Control Semantics (Issue #490). + * + * **CRITICAL:** This test validates the core architectural assumption: + * *"Speed control changes observation speed, NOT simulation semantics."* + * + * Three distinct speed-control mechanisms are validated: + * - [ShuntingLoop] `speedMultiplier` parameter (drives [RealTimeSynch] wall-clock pacing) + * - [ShuntingLoop] `enableRealTimeSync` flag (activates/deactivates [RealTimeSynch]) + * - [SimulationRunner] `speedMultiplier` property (drives [SimulationRunner.throttle]) + * + * None of these must alter the discrete-event simulation timestamps, event order, + * train metrics, physics results, or stochastic train-generation sequence. + * + * **Acceptance criteria (from issue):** + * - All speeds produce identical simulation results + * - Physics invariants hold: v ≥ 0, a ∈ [[MINIMAL_DECELERATION_MS2], [MAXIMAL_ACCELERATION_MS2]] + * - Train length invariant checked by kDisco's [ContinuousInvariantChecker] during run + * - Stochastic distributions unchanged (exponential(43) for Generator, seed=0) + * - Test fails if simulation semantics change + * + * **Speed variants under test:** 0.5x, 1x, 2x, 10x, 50x (issue requirement). + * + * SIM-004 Note: All tests are hard-coded to the vyhybna.xml network topology. + * + * @see SimulationRunner + * @see ShuntingLoop + * @see Issue #490 + */ +@Tag("integration-test") +@DisplayName("Phase 4.1 — Golden Output: Speed Control Semantics") +class SimulationSpeedGoldenTest : KoinTestBase() { + + override fun getTestModule(): Module = integrationTestModule + + // ---- Data types -------------------------------------------------------- + + /** + * Normalised event record for cross-run semantic comparison. + * Train name (source) is intentionally excluded because the static [Train] counter + * increments across successive test runs, making raw names non-comparable. + */ + private data class EventRecord( + val simulationTime: Double, + val eventType: String, + ) + + /** + * All semantic metrics captured from one simulation run. + */ + private data class SimulationSnapshot( + // ShuntingLoop lifecycle counters (#365 instrumentation) + val trainsEntered: Int, + val trainsExited: Int, + val maxConcurrentTrains: Int, + /** Sorted list of per-train block-transition counts — structure comparison without key dependency. */ + val sortedTransitionValues: List, + val totalTransitions: Int, + // Network state at end of simulation + val graphSize: Int, + val occupiedBlocks: Int, + val reservedBlocks: Int, + val trainCount: Int, + // Full event sequence for timestamp / ordering validation + val eventSequence: List, + // Raw TRAIN_CONTINUOUS message bodies for physics validation + val continuousMessages: List, + ) + + // ---- Helper: run one simulation ---------------------------------------- + + /** + * Creates and runs a [ShuntingLoop] against vyhybna.xml, returns a [SimulationSnapshot]. + * + * @param speedMultiplier forwarded to [ShuntingLoop]; only used when [enableRealTimeSync]=true + * @param enableRealTimeSync when false [speedMultiplier] has no effect on simulation semantics + */ + private fun runAtSpeed( + speedMultiplier: Double, + enableRealTimeSync: Boolean = false, + ): SimulationSnapshot { + val factory = get() + val ctx = TestFixtures.loadShuntingXml().use { factory.createContext(it) as DefaultSimulationContext } + + val eventSequence = mutableListOf() + val continuousMessages = mutableListOf() + + val listener = ContextPropertyChangeListener { evt -> + val se = SimulationEvent.fromContextChangeEvent(evt) ?: return@ContextPropertyChangeListener + eventSequence += EventRecord(se.simulationTime, se.eventType.name) + if (se.eventType == ReportType.TRAIN_CONTINUOUS) { + continuousMessages += se.message + } + } + + return ctx.use { context -> + context.getInOuts() + context.addPropertyChangeListener(listener) + + val loop = ShuntingLoop( + context, + END_TIME_SECONDS, + enableRealTimeSync = enableRealTimeSync, + initialSpeedMultiplier = speedMultiplier, + ) + context.setMainProcess(loop) + context.run() + + context.removePropertyChangeListener(listener) + + // graph.values() is Collection (DefaultSimulationContext) + val graph = context.getGraph() + val occupiedBlocks = graph.values().count { it.occupant != null } + val registry = context.scope.get() + val transitions = loop.getAllBlockTransitions() + + logger.info { + "Speed ${if (enableRealTimeSync) "${speedMultiplier}x(rt)" else "${speedMultiplier}x(free)"}: " + + "entered=${loop.getTrainsEntered()}, exited=${loop.getTrainsExited()}, " + + "transitions=${transitions.values.toList()}, events=${eventSequence.size}" + } + + SimulationSnapshot( + trainsEntered = loop.getTrainsEntered(), + trainsExited = loop.getTrainsExited(), + maxConcurrentTrains = loop.getMaxConcurrentTrains(), + sortedTransitionValues = transitions.values.sorted(), + totalTransitions = transitions.values.sum(), + graphSize = graph.size(), + occupiedBlocks = occupiedBlocks, + reservedBlocks = registry.blockCount(), + trainCount = registry.trainCount(), + eventSequence = eventSequence.toList(), + continuousMessages = continuousMessages.toList(), + ) + } + } + + /** + * Runs the simulation through [SimulationRunner.start] to exercise the GUI execution path. + * Blocks until the runner thread exits (≤ 20 s) before returning captured metrics. + * + * @param runnerSpeed value assigned to [SimulationRunner.speedMultiplier] before start + */ + private fun runViaSimulationRunner(runnerSpeed: Double): SimulationSnapshot { + val factory = get() + val ctx = TestFixtures.loadShuntingXml().use { factory.createContext(it) as DefaultSimulationContext } + + val eventSequence = mutableListOf() + val continuousMessages = mutableListOf() + + val listener = ContextPropertyChangeListener { evt -> + val se = SimulationEvent.fromContextChangeEvent(evt) ?: return@ContextPropertyChangeListener + eventSequence += EventRecord(se.simulationTime, se.eventType.name) + if (se.eventType == ReportType.TRAIN_CONTINUOUS) { + continuousMessages += se.message + } + } + + return ctx.use { context -> + context.getInOuts() + context.addPropertyChangeListener(listener) + + val loop = ShuntingLoop(context, END_TIME_SECONDS) + context.setMainProcess(loop) + + val runner = SimulationRunner(context) + runner.speedMultiplier = runnerSpeed + runner.start() + + // Poll until the simulation thread exits (free-run simulation finishes in <3 s) + val deadlineMs = System.currentTimeMillis() + 20_000L + while (runner.isRunning() && System.currentTimeMillis() < deadlineMs) { + Thread.sleep(50) + } + + val timedOut = runner.isRunning() + runner.stop() // interrupt thread if still alive + // Wait for the thread to truly terminate so reads below are safe + val joinDeadlineMs = System.currentTimeMillis() + 5_000L + while (runner.isRunning() && System.currentTimeMillis() < joinDeadlineMs) { + Thread.sleep(10) + } + assertThat(timedOut) + .isFalse() // fail loudly if the simulation hung past the 20 s deadline + + context.removePropertyChangeListener(listener) + + val graph = context.getGraph() + val occupiedBlocks = graph.values().count { it.occupant != null } + val registry = context.scope.get() + val transitions = loop.getAllBlockTransitions() + + SimulationSnapshot( + trainsEntered = loop.getTrainsEntered(), + trainsExited = loop.getTrainsExited(), + maxConcurrentTrains = loop.getMaxConcurrentTrains(), + sortedTransitionValues = transitions.values.sorted(), + totalTransitions = transitions.values.sum(), + graphSize = graph.size(), + occupiedBlocks = occupiedBlocks, + reservedBlocks = registry.blockCount(), + trainCount = registry.trainCount(), + eventSequence = eventSequence.toList(), + continuousMessages = continuousMessages.toList(), + ) + } + } + + // ---- Test 1: Speed-invariant simulation semantics ---------------------- + + /** + * **All speed multipliers produce identical simulation semantics (enableRealTimeSync=false).** + * + * When [ShuntingLoop.enableRealTimeSync] is false, [ShuntingLoop.speedMultiplier] is never + * read by any simulation code. This test proves that varying the parameter across the full + * range required by the issue (0.5x, 1x, 2x, 10x, 50x) leaves every semantic metric unchanged. + * + * Validated invariants: trainsEntered, trainsExited, maxConcurrentTrains, + * sortedTransitionValues, totalTransitions, graphSize, occupiedBlocks, reservedBlocks, trainCount. + */ + @Test + @Tag("integration-test") + @Timeout(value = 120, unit = TimeUnit.SECONDS) + @DisplayName("all speed multipliers produce identical simulation semantics (free-run)") + fun `all speed multipliers produce identical simulation semantics`() { + val baseline = runAtSpeed(speedMultiplier = 1.0) + + // Baseline must contain at least one train — proves simulation ran meaningfully + assertThat(baseline.trainsEntered).isGreaterThanOrEqualTo(1) + assertThat(baseline.graphSize).isEqualTo(EXPECTED_GRAPH_SIZE) + + for (speed in SPEED_VARIANTS) { + val result = runAtSpeed(speedMultiplier = speed) + + assertThat(result.trainsEntered).isEqualTo(baseline.trainsEntered) + assertThat(result.trainsExited).isEqualTo(baseline.trainsExited) + assertThat(result.maxConcurrentTrains).isEqualTo(baseline.maxConcurrentTrains) + assertThat(result.sortedTransitionValues).isEqualTo(baseline.sortedTransitionValues) + assertThat(result.totalTransitions).isEqualTo(baseline.totalTransitions) + assertThat(result.graphSize).isEqualTo(baseline.graphSize) + assertThat(result.occupiedBlocks).isEqualTo(baseline.occupiedBlocks) + assertThat(result.reservedBlocks).isEqualTo(baseline.reservedBlocks) + assertThat(result.trainCount).isEqualTo(baseline.trainCount) + } + } + + // ---- Test 2: Event-sequence identity ----------------------------------- + + /** + * **Simulation event sequences (timestamps + types) are identical across all speed multipliers.** + * + * For each speed variant, every captured event must match the baseline in: + * - `simulationTime` (exact equality; discrete-event timestamps are deterministic Doubles) + * - `eventType` (same ReportType at each position) + * + * This is the definitive proof that "speed" is a pure observation parameter: + * the discrete-event simulation clock advances identically regardless of the + * wall-clock speed multiplier. + */ + @Test + @Tag("integration-test") + @Timeout(value = 120, unit = TimeUnit.SECONDS) + @DisplayName("event sequences (timestamps + types) identical across all speed multipliers") + fun `event sequences identical across all speed multipliers`() { + val baseline = runAtSpeed(speedMultiplier = 1.0) + assertThat(baseline.eventSequence).isNotEmpty() + + for (speed in SPEED_VARIANTS) { + val result = runAtSpeed(speedMultiplier = speed) + + assertThat(result.eventSequence).hasSize(baseline.eventSequence.size) + + result.eventSequence.zip(baseline.eventSequence).forEach { (actual, expected) -> + assertThat(actual.eventType).isEqualTo(expected.eventType) + assertThat(actual.simulationTime).isEqualTo(expected.simulationTime) + } + } + } + + // ---- Test 3: RealTimeSynch invariance at high speed -------------------- + + /** + * **Running with [enableRealTimeSync]=true at [REALTIME_TEST_SPEED]x produces semantics + * identical to the unthrottled baseline.** + * + * Wall-clock cost: [END_TIME_SECONDS] / [REALTIME_TEST_SPEED] = ~1.2 s. + * + * [RealTimeSynch] calls [Thread.sleep] to pace wall-clock time and calls `hold(1+overshoot)` + * for its own simulation-time bookkeeping. Neither operation touches the [Generator]'s random + * stream or alters when other processes are scheduled — so all discrete-event timestamps, + * event types, and train counts remain identical to the free-run baseline. + */ + @Test + @Tag("integration-test") + @Timeout(value = 60, unit = TimeUnit.SECONDS) + @DisplayName("RealTimeSynch at ${REALTIME_TEST_SPEED}x does not alter simulation semantics") + fun `RealTimeSynch at high speed does not alter simulation semantics`() { + val baseline = runAtSpeed(speedMultiplier = 1.0, enableRealTimeSync = false) + val realtime = runAtSpeed(speedMultiplier = REALTIME_TEST_SPEED, enableRealTimeSync = true) + + assertThat(realtime.trainsEntered).isEqualTo(baseline.trainsEntered) + assertThat(realtime.trainsExited).isEqualTo(baseline.trainsExited) + assertThat(realtime.sortedTransitionValues).isEqualTo(baseline.sortedTransitionValues) + assertThat(realtime.totalTransitions).isEqualTo(baseline.totalTransitions) + assertThat(realtime.graphSize).isEqualTo(baseline.graphSize) + assertThat(realtime.occupiedBlocks).isEqualTo(baseline.occupiedBlocks) + assertThat(realtime.reservedBlocks).isEqualTo(baseline.reservedBlocks) + assertThat(realtime.trainCount).isEqualTo(baseline.trainCount) + + assertThat(realtime.eventSequence).hasSize(baseline.eventSequence.size) + realtime.eventSequence.zip(baseline.eventSequence).forEach { (actual, expected) -> + assertThat(actual.eventType).isEqualTo(expected.eventType) + assertThat(actual.simulationTime).isEqualTo(expected.simulationTime) + } + } + + // ---- Test 4: Physics invariants ---------------------------------------- + + /** + * **Physics invariants hold throughout the full 60 s simulation run.** + * + * Validated per each 1 Hz [ReportType.TRAIN_CONTINUOUS] sample: + * - Velocity ≥ 0 m/s (trains move forward only) + * - Acceleration ∈ [[MINIMAL_DECELERATION_MS2], [MAXIMAL_ACCELERATION_MS2]] m/s² + * + * The kinematic equation v²=u²+2as is the formula implemented in `Motor.derivatives()`. + * That motor logic is unit-tested by [TrainPhysicsTest]; here we confirm the equation's + * outputs (velocity and acceleration) remain within valid physical bounds throughout a + * full multi-train run at every reported sample. + * + * The front−tail=length invariant is enforced by [ContinuousInvariantChecker] (LengthChecker) + * at every ODE integration step during the run — if it is violated, kDisco throws a + * [SimulationException] and the test fails before reaching these assertions. + * + * Tolerance: [POSITION_TOLERANCE_M] = 1e-6 m (issue acceptance criterion). + */ + @Test + @Tag("integration-test") + @Timeout(value = 60, unit = TimeUnit.SECONDS) + @DisplayName("physics invariants: v≥0 and a∈[MINIMAL_DECELERATION, MAXIMAL_ACCELERATION]") + fun `physics invariants hold across full simulation run`() { + val snapshot = runAtSpeed(speedMultiplier = 1.0) + + // Must have TRAIN_CONTINUOUS events — proves at least one train was running + assertThat(snapshot.continuousMessages).isNotEmpty() + + for (msg in snapshot.continuousMessages) { + // se.message = "#N {acceleration} {velocity} ..." (parts[0] is the train-id fragment) + val parts = msg.trim().split(Regex("\\s+")) + assertThat(parts.size) + .isGreaterThanOrEqualTo(3) // message format: #N acceleration velocity ... + val acceleration = requireNotNull(parts[1].toDoubleOrNull()) { + "TRAIN_CONTINUOUS acceleration token must be numeric, got: '${parts[1]}' in: '$msg'" + } + val velocity = requireNotNull(parts[2].toDoubleOrNull()) { + "TRAIN_CONTINUOUS velocity token must be numeric, got: '${parts[2]}' in: '$msg'" + } + + // Velocity must be non-negative (trains only move forward) + assertThat(velocity) + .isGreaterThanOrEqualTo(-POSITION_TOLERANCE_M) + + // Acceleration must be within physical motor bounds + assertThat(acceleration) + .isGreaterThanOrEqualTo(MINIMAL_DECELERATION_MS2 - POSITION_TOLERANCE_M) + assertThat(acceleration) + .isLessThanOrEqualTo(MAXIMAL_ACCELERATION_MS2 + POSITION_TOLERANCE_M) + } + } + + // ---- Test 5: Train final positions within tolerance -------------------- + + /** + * **Train front-positions recorded in [ReportType.TRAIN_CONTINUOUS] events are identical + * (within [POSITION_TOLERANCE_M] = 1e-6 m) across all speed multipliers.** + * + * The sorted list of front-distances at each simulated second is compared across all speed + * variants against the 1x baseline. Because the simulation is fully deterministic (same RNG + * seed, same event schedule), positions are in practice exactly equal; the tolerance accounts + * for any future floating-point edge cases in the numerical integrator. + */ + @Test + @Tag("integration-test") + @Timeout(value = 120, unit = TimeUnit.SECONDS) + @DisplayName("train front-positions within 1e-6 m tolerance across all speed multipliers") + fun `train final positions within tolerance across all speed multipliers`() { + val baseline = runAtSpeed(speedMultiplier = 1.0) + val baselinePositions = extractFrontDistances(baseline.continuousMessages) + assertThat(baselinePositions).isNotEmpty() + + for (speed in SPEED_VARIANTS) { + val result = runAtSpeed(speedMultiplier = speed) + val positions = extractFrontDistances(result.continuousMessages) + + assertThat(positions).hasSize(baselinePositions.size) + positions.zip(baselinePositions).forEach { (actual, expected) -> + assertThat(abs(actual - expected)).isLessThanOrEqualTo(POSITION_TOLERANCE_M) + } + } + } + + // ---- Test 6: Stochastic reproducibility -------------------------------- + + /** + * **The [Generator] produces identical train-generation sequences across independent runs.** + * + * [Generator] uses a [cz.hovorka.kdisco.Random] seeded with `0L` and inter-arrival + * distribution `exp(43.0)`. Two independent runs from the same seed must produce + * identical event timestamps, event types, and all train-lifecycle metrics. + * + * This verifies that [ShuntingLoop.speedMultiplier] (when unused) never consumes from + * the random stream, and that the simulation framework provides deterministic replay. + */ + @Test + @Tag("integration-test") + @Timeout(value = 120, unit = TimeUnit.SECONDS) + @DisplayName("stochastic train generation (exp(43), seed=0) is reproducible across runs") + fun `stochastic train generation is reproducible`() { + val run1 = runAtSpeed(speedMultiplier = 1.0) + val run2 = runAtSpeed(speedMultiplier = 1.0) + + assertThat(run2.trainsEntered).isEqualTo(run1.trainsEntered) + assertThat(run2.trainsExited).isEqualTo(run1.trainsExited) + assertThat(run2.sortedTransitionValues).isEqualTo(run1.sortedTransitionValues) + assertThat(run2.totalTransitions).isEqualTo(run1.totalTransitions) + + // Event sequence must be identical (timestamps + event types) + assertThat(run2.eventSequence).hasSize(run1.eventSequence.size) + run2.eventSequence.zip(run1.eventSequence).forEach { (e2, e1) -> + assertThat(e2.simulationTime).isEqualTo(e1.simulationTime) + assertThat(e2.eventType).isEqualTo(e1.eventType) + } + } + + // ---- Test 7: SimulationRunner speed invariance ------------------------- + + /** + * **[SimulationRunner.speedMultiplier] does not alter the discrete-event simulation output.** + * + * [SimulationRunner.speedMultiplier] only affects the wall-clock sleep duration in + * [SimulationRunner.throttle]. Because [ShuntingLoop] does not call [SimulationRunner.throttle], + * changing the runner's speed has zero effect on the discrete-event simulation output. + * + * This test runs the simulation through the full GUI execution path ([SimulationRunner.start]) + * at three distinct speed settings and verifies the output matches the direct `context.run()` + * baseline at 1x. + */ + @Test + @Tag("integration-test") + @Timeout(value = 120, unit = TimeUnit.SECONDS) + @DisplayName("SimulationRunner speed multiplier does not alter simulation output") + fun `SimulationRunner speed multiplier does not alter simulation output`() { + val baseline = runAtSpeed(speedMultiplier = 1.0) + + for (runnerSpeed in listOf(0.5, 2.0, 10.0)) { + val result = runViaSimulationRunner(runnerSpeed) + + assertThat(result.trainsEntered).isEqualTo(baseline.trainsEntered) + assertThat(result.trainsExited).isEqualTo(baseline.trainsExited) + assertThat(result.sortedTransitionValues).isEqualTo(baseline.sortedTransitionValues) + assertThat(result.totalTransitions).isEqualTo(baseline.totalTransitions) + assertThat(result.graphSize).isEqualTo(baseline.graphSize) + assertThat(result.occupiedBlocks).isEqualTo(baseline.occupiedBlocks) + assertThat(result.reservedBlocks).isEqualTo(baseline.reservedBlocks) + assertThat(result.trainCount).isEqualTo(baseline.trainCount) + } + } + + // ---- Helper: extract front distances from continuous events ------------ + + /** + * Parses TRAIN_CONTINUOUS message bodies and returns sorted front-distances. + * + * Message format (from [TrainReporter]): + * `#N {acceleration} {velocity} {frontTotalDistance} {frontSection} {tailSection} {distToSem}` + * (parts[0] is the train-id fragment from SimulationEvent.fromContextChangeEvent's limit-3 split) + * + * Returns sorted values for key-independent structural comparison across runs + * (train names / source differ between runs due to static counter). + */ + private fun extractFrontDistances(messages: List): List = + messages.map { msg -> + val parts = msg.trim().split(Regex("\\s+")) + require(parts.size >= 4) { + "TRAIN_CONTINUOUS message must have at least 4 tokens, got ${parts.size}: '$msg'" + } + requireNotNull(parts[3].toDoubleOrNull()) { + "TRAIN_CONTINUOUS front-distance token must be numeric, got: '${parts[3]}' in: '$msg'" + } + }.sorted() + + // ---- Constants --------------------------------------------------------- + + companion object { + /** Simulation end time (simulation seconds). */ + const val END_TIME_SECONDS = 60L + + /** + * Position tolerance from issue acceptance criteria. + * Used for velocity ≥ 0 checks and front-distance cross-run comparison. + */ + const val POSITION_TOLERANCE_M = 1e-6 + + /** Physical acceleration upper bound from Train.Motor (m/s²). */ + const val MAXIMAL_ACCELERATION_MS2 = 4.0 + + /** Physical deceleration lower bound from Train.Motor (m/s²). */ + const val MINIMAL_DECELERATION_MS2 = -3.0 + + /** + * Expected graph size for vyhybna.xml — stable baseline from [KoinGoldenOutputTest]. + * Confirmed with kdisco-engine 0.3.0-SNAPSHOT; validated by [KoinGoldenOutputTest]. + */ + const val EXPECTED_GRAPH_SIZE = 10 + + /** + * Speed used for the [RealTimeSynch] invariance test. + * Wall-clock cost: [END_TIME_SECONDS] / [REALTIME_TEST_SPEED] ≈ 1.2 s. + */ + const val REALTIME_TEST_SPEED = 50.0 + + /** Speed variants required by the issue (0.5x, 1x, 2x, 10x, 50x). */ + val SPEED_VARIANTS = listOf(0.5, 1.0, 2.0, 10.0, 50.0) + } +} diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationSpeedIntegrationTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationSpeedIntegrationTest.kt new file mode 100644 index 000000000..5af7734ea --- /dev/null +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationSpeedIntegrationTest.kt @@ -0,0 +1,486 @@ +/* + Brno University of Technology + Faculty of Information Technology + + BSc Thesis 2006/2007 + Railway Interlocking Simulator + + Integration tests for simulation speed control (Goal 7, Phase 4.3) +*/ + +package cz.vutbr.fit.interlockSim.gui + +import assertk.assertThat +import assertk.assertions.isNull +import assertk.assertions.isTrue +import cz.vutbr.fit.interlockSim.context.SimulationContext +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import javax.swing.SwingUtilities + +/** + * Integration tests for [SimulationRunner] speed control under real concurrent conditions + * (Goal 7, Phase 4.3). + * + * These tests complement the unit tests in [SimulationRunnerTest] by exercising + * real multi-threaded scenarios: actual [Thread.sleep] throttling, concurrent + * speed changes, pause/resume blocking, and stop-under-load. + * + * All tests are tagged [Tag] "integration-test" and run via `./gradlew integrationTest`. + * + * Acceptance criteria verified: + * - No crashes or deadlocks during rapid speed changes + * - Pause/resume works correctly at all speeds + * - Stop always completes within 5 seconds + * - No race conditions between EDT and simulation thread + * + * Test scenarios (12 tests total): + * 1. Rapid speed changes (50 changes every 10ms) do not crash or deadlock + * 2. Pause and resume at 0.1× speed do not deadlock + * 3. Pause and resume at 1× speed do not deadlock + * 4. Pause and resume at 10× speed do not deadlock + * 5. Ten consecutive pause/resume cycles complete cleanly + * 6. Stop at 0.1× (slow) speed completes within 5 seconds + * 7. Stop while paused completes within 5 seconds + * 8. Concurrent speed changes from 4 threads cause no data races + * 9. Stop at simulated 25% completion terminates cleanly + * 10. Stop at simulated 50% completion terminates cleanly + * 11. Stop at simulated 75% completion terminates cleanly + * 12. Speed changes posted from EDT do not race with the simulation thread + * + * @see SimulationRunner + * @see SimulationRunnerTest for unit-level tests + */ +@Tag("integration-test") +@DisplayName("SimulationSpeed Integration") +class SimulationSpeedIntegrationTest { + private lateinit var context: SimulationContext + private lateinit var runner: SimulationRunner + + @BeforeEach + fun setUp() { + context = mockk(relaxed = true) + runner = SimulationRunner(context) + } + + @AfterEach + fun tearDown() { + // Unpause first so the simulation thread can receive the interrupt cleanly. + runner.isPaused = false + runner.stop() + // Bounded wait so a failing test does not leak its thread into the next one. + waitForStop(5_000) + } + + // ── 1. Rapid speed changes ──────────────────────────────────────────────── + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("50 rapid speed changes every 10ms do not crash or deadlock") + fun rapidSpeedChangesNoCrash() { + val simRunning = CountDownLatch(1) + every { context.run() } answers { + simRunning.countDown() + try { + while (!Thread.currentThread().isInterrupted) { + // throttle(0.1) at 100x → sleepMs = round(0.1/100*1000) = 1ms: no busy spin. + runner.throttle(0.1) + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + runner.speedMultiplier = 100.0 + runner.start() + assertThat(simRunning.await(5, TimeUnit.SECONDS)).isTrue() + + val speeds = doubleArrayOf(0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0) + val changesDone = CountDownLatch(1) + Thread { + try { + repeat(50) { i -> + runner.speedMultiplier = speeds[i % speeds.size] + Thread.sleep(10) + } + } catch (_: InterruptedException) { + // ignored + } finally { + changesDone.countDown() + } + }.also { + it.isDaemon = true + it.start() + } + + assertThat(changesDone.await(10, TimeUnit.SECONDS)).isTrue() + assertThat(runner.isRunning()).isTrue() + + runner.stop() + assertThat(waitForStop(5_000)).isTrue() + } + + // ── 2. Pause/resume at different speeds ─────────────────────────────────── + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("pause and resume at 0.1x speed do not deadlock") + fun pauseResumeAt01xSpeed() { + verifyPauseResumeAtSpeed(0.1) + } + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("pause and resume at 1x speed do not deadlock") + fun pauseResumeAt1xSpeed() { + verifyPauseResumeAtSpeed(1.0) + } + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("pause and resume at 10x speed do not deadlock") + fun pauseResumeAt10xSpeed() { + verifyPauseResumeAtSpeed(10.0) + } + + // ── 3. Multiple pause/resume cycles ────────────────────────────────────── + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + @DisplayName("10 pause/resume cycles complete without deadlock") + fun multiplePauseResumeCycles() { + val simRunning = CountDownLatch(1) + every { context.run() } answers { + simRunning.countDown() + try { + while (!Thread.currentThread().isInterrupted) { + // throttle(0.1) at 100x → sleepMs = 1ms: avoids busy spin. + runner.throttle(0.1) + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + runner.speedMultiplier = 100.0 + runner.start() + assertThat(simRunning.await(5, TimeUnit.SECONDS)).isTrue() + + repeat(10) { + runner.isPaused = true + Thread.sleep(10) + runner.isPaused = false + // Verify the sim thread is still alive after each cycle — catches early death. + assertThat(runner.isRunning()).isTrue() + Thread.sleep(10) + } + runner.stop() + assertThat(waitForStop(5_000)).isTrue() + } + + // ── 4. Stop at slow speed ───────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("stop at 0.1x speed completes within 5 seconds") + fun stopAtSlowSpeedCompletesWithinFiveSeconds() { + val simRunning = CountDownLatch(1) + every { context.run() } answers { + simRunning.countDown() + try { + while (!Thread.currentThread().isInterrupted) { + runner.throttle(0.1) + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + // At 0.1x speed each throttle(0.1) sleeps 1000ms; stop() interrupts that sleep. + runner.speedMultiplier = 0.1 + runner.start() + assertThat(simRunning.await(5, TimeUnit.SECONDS)).isTrue() + + // waitForStop(5_000) + @Timeout(10s) together enforce the 5-second bound + // without a flaky wall-clock comparison that can fail on busy CI runners. + runner.stop() + assertThat(waitForStop(5_000)).isTrue() + } + + // ── 5. Stop while paused ────────────────────────────────────────────────── + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + @DisplayName("stop while paused completes within 5 seconds") + fun stopWhilePausedCompletesWithinFiveSeconds() { + val simRunning = CountDownLatch(1) + // Fires once the sim thread has observed isPaused=true and is about to block. + val enteringPauseWait = CountDownLatch(1) + + every { context.run() } answers { + simRunning.countDown() + try { + while (!Thread.currentThread().isInterrupted) { + if (runner.isPaused) { + // Signal before blocking so the test knows the thread is paused. + enteringPauseWait.countDown() + runner.awaitIfPaused() + } else { + Thread.sleep(10) + } + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + runner.start() + assertThat(simRunning.await(5, TimeUnit.SECONDS)).isTrue() + + runner.isPaused = true + // Wait until the sim thread has actually entered the paused-wait state. + assertThat(enteringPauseWait.await(5, TimeUnit.SECONDS)).isTrue() + + // waitForStop(5_000) + @Timeout(10s) enforce the bound without a flaky comparison. + runner.stop() + assertThat(waitForStop(5_000)).isTrue() + } + + // ── 6. Concurrent speed changes ─────────────────────────────────────────── + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("concurrent speed changes from 4 threads cause no data races") + fun concurrentSpeedChangesFromMultipleThreads() { + val simRunning = CountDownLatch(1) + val failure = AtomicReference(null) + + every { context.run() } answers { + simRunning.countDown() + try { + while (!Thread.currentThread().isInterrupted) { + // throttle(0.1) at 100x → sleepMs = 1ms: no busy spin. + runner.throttle(0.1) + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + runner.speedMultiplier = 100.0 + runner.start() + assertThat(simRunning.await(5, TimeUnit.SECONDS)).isTrue() + + val allReady = CountDownLatch(4) + val allDone = CountDownLatch(4) + val speedSets = listOf( + doubleArrayOf(0.1, 0.5, 1.0), + doubleArrayOf(2.0, 5.0, 10.0), + doubleArrayOf(20.0, 50.0, 100.0), + doubleArrayOf(1.0, 3.0, 7.0), + ) + + speedSets.forEach { speeds -> + Thread { + allReady.countDown() + // Assert the await so a slow start is caught rather than silently skipped. + assertThat(allReady.await(5, TimeUnit.SECONDS)).isTrue() + try { + repeat(20) { i -> + runner.speedMultiplier = speeds[i % speeds.size] + Thread.sleep(5) + } + } catch (e: Exception) { + failure.compareAndSet(null, e) + } finally { + allDone.countDown() + } + }.also { + it.isDaemon = true + it.start() + } + } + + assertThat(allDone.await(10, TimeUnit.SECONDS)).isTrue() + assertThat(failure.get()).isNull() + assertThat(runner.isRunning()).isTrue() + + runner.stop() + assertThat(waitForStop(5_000)).isTrue() + } + + // ── 7. Stop at various progress points ─────────────────────────────────── + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("stop at simulated 25% completion terminates cleanly") + fun stopAt25PercentProgress() { + verifyStopAfterIterations(25) + } + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("stop at simulated 50% completion terminates cleanly") + fun stopAt50PercentProgress() { + verifyStopAfterIterations(50) + } + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("stop at simulated 75% completion terminates cleanly") + fun stopAt75PercentProgress() { + verifyStopAfterIterations(75) + } + + // ── 8. Speed changes from EDT ───────────────────────────────────────────── + + @Test + @Timeout(value = 15, unit = TimeUnit.SECONDS) + @DisplayName("speed changes posted from EDT do not race with simulation thread") + fun speedChangesFromEdtNoRaceConditions() { + val simRunning = CountDownLatch(1) + val failure = AtomicReference(null) + + every { context.run() } answers { + simRunning.countDown() + try { + while (!Thread.currentThread().isInterrupted) { + // throttle(0.1) at 100x → sleepMs = 1ms: no busy spin. + runner.throttle(0.1) + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + runner.speedMultiplier = 100.0 + runner.start() + assertThat(simRunning.await(5, TimeUnit.SECONDS)).isTrue() + + // Simulate GUI speed changes arriving on the Swing Event Dispatch Thread. + val edtDone = CountDownLatch(1) + SwingUtilities.invokeLater { + try { + listOf(1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 0.5, 0.1, 1.0).forEach { speed -> + runner.speedMultiplier = speed + } + } catch (e: Exception) { + failure.set(e) + } finally { + edtDone.countDown() + } + } + + assertThat(edtDone.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(failure.get()).isNull() + assertThat(runner.isRunning()).isTrue() + + runner.stop() + assertThat(waitForStop(5_000)).isTrue() + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + /** + * Start a simulation at [speed], pause it, wait until the sim thread has + * actually entered [SimulationRunner.awaitIfPaused], resume it, wait for the + * thread to exit the pause wait, and verify the runner is still alive throughout. + * No deadlock or crash. + */ + private fun verifyPauseResumeAtSpeed(speed: Double) { + val simRunning = CountDownLatch(1) + // Fires once the sim thread has seen isPaused=true and is about to block. + val enteringPauseWait = CountDownLatch(1) + // Fires once the sim thread has exited awaitIfPaused() after resuming. + val resumedFromPause = CountDownLatch(1) + every { context.run() } answers { + simRunning.countDown() + try { + while (!Thread.currentThread().isInterrupted) { + if (runner.isPaused) { + // Signal before blocking so the test can observe it reliably. + enteringPauseWait.countDown() + runner.awaitIfPaused() + // Signal after unblocking so the test knows the thread resumed. + if (resumedFromPause.count > 0L) resumedFromPause.countDown() + } else { + runner.throttle(0.01) + } + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + runner.speedMultiplier = speed + runner.start() + assertThat(simRunning.await(5, TimeUnit.SECONDS)).isTrue() + + runner.isPaused = true + // Wait until the sim thread has actually entered the paused-wait state. + assertThat(enteringPauseWait.await(5, TimeUnit.SECONDS)).isTrue() + + runner.isPaused = false + // Wait until the sim thread has actually exited awaitIfPaused() and resumed. + assertThat(resumedFromPause.await(5, TimeUnit.SECONDS)).isTrue() + + assertThat(runner.isRunning()).isTrue() + runner.stop() + assertThat(waitForStop(5_000)).isTrue() + } + + /** + * Run an infinite simulation loop, stop it after at least [iterations] throttle + * calls have completed, and verify the runner terminates cleanly. + * + * This simulates stopping mid-simulation: the infinite loop guarantees the runner + * is still active when [SimulationRunner.stop] is called. + */ + private fun verifyStopAfterIterations(iterations: Int) { + val latch = CountDownLatch(iterations) + every { context.run() } answers { + try { + while (!Thread.currentThread().isInterrupted) { + // throttle(0.1) at 100x → sleepMs = 1ms: no busy spin. + runner.throttle(0.1) + if (latch.count > 0L) latch.countDown() + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + runner.speedMultiplier = 100.0 + runner.start() + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue() + // The while-loop exits only on interrupt; stop() has not been called yet, + // so context.run() is guaranteed still executing here. + assertThat(runner.isRunning()).isTrue() + + runner.stop() + assertThat(waitForStop(5_000)).isTrue() + } + + /** + * Polls [SimulationRunner.isRunning] until the runner stops or [timeoutMs] elapses. + * + * @return `true` if the runner stopped within the timeout, `false` on timeout. + */ + private fun waitForStop(timeoutMs: Long): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (runner.isRunning()) { + if (System.currentTimeMillis() >= deadline) return false + Thread.sleep(50) + } + return true + } +} diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationSpeedPerformanceTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationSpeedPerformanceTest.kt new file mode 100644 index 000000000..6b3c0f746 --- /dev/null +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationSpeedPerformanceTest.kt @@ -0,0 +1,273 @@ +/* + Brno University of Technology + Faculty of Information Technology + + BSc Thesis 2006/2007 + Railway Interlocking Simulator + + Performance benchmarks for SimulationRunner speed control (Phase 4.2, Issue #197) +*/ + +package cz.vutbr.fit.interlockSim.gui + +import assertk.assertThat +import assertk.assertions.isGreaterThan +import assertk.assertions.isLessThan +import assertk.assertions.isTrue +import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext +import cz.vutbr.fit.interlockSim.context.SimulationContext +import cz.vutbr.fit.interlockSim.context.SimulationContextFactory +import cz.vutbr.fit.interlockSim.sim.ShuntingLoop +import cz.vutbr.fit.interlockSim.testutil.KoinTestBase +import cz.vutbr.fit.interlockSim.testutil.TestFixtures +import cz.vutbr.fit.interlockSim.testutil.integrationTestModule +import io.github.oshai.kotlinlogging.KotlinLogging +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.koin.core.module.Module +import org.koin.test.get +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import javax.swing.SwingUtilities + +/** + * Performance benchmarks for [SimulationRunner] speed control (Phase 4.2, Issue #197). + * + * Validates: + * - SimulationRunner overhead vs raw context.run() is < 1% (target; CI-safe bound 5%) + * - throttle() wall-clock accuracy at 0.1×, 10×, and 100× speed multipliers + * - Full 300s ShuntingLoop simulation completes quickly at 100× (CPU-bound, no throttle) + * - EDT invokeLater latency < 500ms (CI-safe) while background sim thread is blocked + * + * All tests are tagged @Tag("integration-test") and run via `./gradlew integrationTest`. + * + * ## Performance Targets + * + * | Speed | Expected Behaviour | + * |--------|---------------------------------------------------------------| + * | 0.1× | Wall-clock = sim-time × 10 (±10%), tested via throttle(0.1s) | + * | 1× | Wall-clock = sim-time (±5%), overhead < 1% | + * | 10× | ~100ms wall-clock per 1s sim event (CI bound 80–500ms) | + * | 100× | CPU-bound for small sim deltas; 1000 × 0.001s events < 500ms wall-clock | + * + * @see SimulationRunner + * @see Issue #197 + */ +@DisplayName("SimulationRunner Speed Performance (Phase 4.2)") +@Tag("integration-test") +class SimulationSpeedPerformanceTest : KoinTestBase() { + + override fun getTestModule(): Module = integrationTestModule + + private val logger = KotlinLogging.logger {} + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun loadShuntingLoop(endTime: Long): DefaultSimulationContext { + val factory = get() + val ctx = TestFixtures.loadShuntingXml().use { factory.createContext(it) } + as DefaultSimulationContext + ctx.getInOuts() + ctx.setMainProcess(ShuntingLoop(ctx, endTime)) + return ctx + } + + // ── Overhead: SimulationRunner vs raw context.run() ─────────────────────── + + /** + * Measures the overhead added by [SimulationRunner] relative to a direct + * context.run() call. The dominant cost is thread creation (~1 ms), which + * is negligible for any real simulation (target < 1%; CI-safe bound 5%). + * + * Uses a 3 s baseline so that 5% tolerance = ~150ms, providing sufficient + * headroom for OS scheduling jitter on contended CI runners. + */ + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + @DisplayName("overhead vs raw context.run() is negligible (< 5% CI-safe; target < 1%)") + fun runnerOverheadIsNegligible() { + val sleepMs = 3_000L + + // Baseline: direct context.run() call + val baseCtx = mockk(relaxed = true) + every { baseCtx.run() } answers { Thread.sleep(sleepMs) } + val baselineNs = System.nanoTime() + baseCtx.run() + val baselineMs = (System.nanoTime() - baselineNs) / 1_000_000.0 + + // With SimulationRunner: thread is created, context.run() called inside it + val runCtx = mockk(relaxed = true) + val done = CountDownLatch(1) + every { runCtx.run() } answers { Thread.sleep(sleepMs); done.countDown() } + val runner = SimulationRunner(runCtx) + val runnerNs = System.nanoTime() + runner.start() + assertThat(done.await(10, TimeUnit.SECONDS)).isTrue() + val runnerMs = (System.nanoTime() - runnerNs) / 1_000_000.0 + + val overheadPct = (runnerMs - baselineMs) / baselineMs * 100.0 + logger.info { + "Overhead: ${"%.2f".format(overheadPct)}% " + + "(baseline=${"%.1f".format(baselineMs)}ms, runner=${"%.1f".format(runnerMs)}ms)" + } + // Primary target: < 1%. Allow up to 5% for OS scheduling noise in CI. + assertThat(overheadPct).isLessThan(5.0) + } + + // ── Throttle accuracy ───────────────────────────────────────────────────── + + /** + * At 100× speed with small sim deltas (0.001s per event — typical for kDisco): + * sleepMs = round(0.001 / 100 × 1000) = round(0.01) = 0 → no sleep. + * 1000 such events should complete in < 500ms (pure CPU overhead, no Thread.sleep). + * + * Note: throttle(1.0) at 100× still sleeps 10ms (1.0/100×1000=10ms). + * "100× = CPU-bound, no throttling" applies to realistic small sim deltas. + */ + @Test + @Timeout(10, unit = TimeUnit.SECONDS) + @DisplayName("at 100x: 1000 x 0.001s sim events (typical kDisco step) complete in < 500ms") + fun throttleAt100xWithSmallDeltasIsNearZero() { + val runner = SimulationRunner(mockk(relaxed = true)) + runner.speedMultiplier = 100.0 + + val startNs = System.nanoTime() + repeat(1000) { runner.throttle(0.001) } // sleepMs = round(0.001/100×1000) = 0ms + val elapsedMs = (System.nanoTime() - startNs) / 1_000_000 + + logger.info { "100×: 1000 × 0.001s sim events took ${elapsedMs}ms wall-clock (target: CPU-bound, no sleep)" } + assertThat(elapsedMs).isLessThan(500) + } + + /** + * At 10× speed: sleepMs = round(1.0 / 10 × 1000) = 100ms per 1s sim event. + * Verified range 80–500ms (lower: JVM sleep precision; upper: CI scheduling). + */ + @Test + @Timeout(10, unit = TimeUnit.SECONDS) + @DisplayName("at 10x: 1s sim event takes ~100ms wall-clock (80-500ms CI-safe)") + fun throttleAt10xDelivers100msPerSimSecond() { + val runner = SimulationRunner(mockk(relaxed = true)) + runner.speedMultiplier = 10.0 + + val startNs = System.nanoTime() + runner.throttle(1.0) + val elapsedMs = (System.nanoTime() - startNs) / 1_000_000 + + logger.info { "10×: 1s sim throttle took ${elapsedMs}ms wall-clock (target: ~100ms)" } + assertThat(elapsedMs).isGreaterThan(80) + assertThat(elapsedMs).isLessThan(500) + } + + /** + * At 0.1× speed: sleepMs = round(0.1 / 0.1 × 1000) = 1000ms per 0.1s sim event. + * Verifies the simulation is slowed 10× relative to real time (±10%, CI 3s bound). + */ + @Test + @Timeout(10, unit = TimeUnit.SECONDS) + @DisplayName("at 0.1x: 0.1s sim event takes ~1000ms wall-clock (900-3000ms CI-safe)") + fun throttleAt01xIs10xSlowerThanRealTime() { + val runner = SimulationRunner(mockk(relaxed = true)) + runner.speedMultiplier = 0.1 + + val startNs = System.nanoTime() + runner.throttle(0.1) + val elapsedMs = (System.nanoTime() - startNs) / 1_000_000 + + logger.info { "0.1×: 0.1s sim throttle took ${elapsedMs}ms wall-clock (target: ~1000ms)" } + assertThat(elapsedMs).isGreaterThan(900) + assertThat(elapsedMs).isLessThan(3000) + } + + // ── Full simulation at 100× ─────────────────────────────────────────────── + + /** + * Runs a real 300s ShuntingLoop at 100× speed via [SimulationRunner]. + * Since [ShuntingLoop] does not call throttle(), the simulation runs CPU-bound + * regardless of speed setting — verifying no unexpected wall-clock throttling. + * + * Completion within the @Timeout budget is the primary assertion; the elapsed + * duration is logged for trend monitoring but not asserted, to avoid flakiness + * on slower/contended CI runners. + */ + @Test + @Timeout(60, unit = TimeUnit.SECONDS) + @DisplayName("300s ShuntingLoop at 100x completes within @Timeout budget (logs wall-clock)") + fun shuntingLoop300sAt100xCompletesQuickly() { + loadShuntingLoop(300L).use { ctx -> + val runner = SimulationRunner(ctx) + runner.speedMultiplier = 100.0 + + val done = CountDownLatch(1) + val monitor = Thread { + while (runner.isRunning()) Thread.sleep(50) + done.countDown() + } + monitor.isDaemon = true + + val startNs = System.nanoTime() + runner.start() + monitor.start() + assertThat(done.await(55, TimeUnit.SECONDS)).isTrue() + val elapsedMs = (System.nanoTime() - startNs) / 1_000_000 + + // Log for trend monitoring; hard assertion omitted to avoid CI flakiness. + logger.info { "ShuntingLoop 300s at 100×: ${elapsedMs}ms wall-clock" } + } + } + + // ── EDT responsiveness ──────────────────────────────────────────────────── + + /** + * Verifies that [SwingUtilities.invokeLater] dispatch latency remains below 500ms + * while a background simulation thread exists (blocked on a latch, not CPU-busy). + * + * This test validates the baseline EDT availability: the simulation thread must + * not hold any lock that could block the EDT. A separate warmup event ensures + * the EDT is already active before the measured dispatch. + * + * Note: the simulation thread is blocked on a latch, not CPU-busy, so this + * test does not exercise EDT responsiveness under actual CPU contention. + */ + @Test + @Timeout(30, unit = TimeUnit.SECONDS) + @DisplayName("EDT invokeLater latency is < 500ms while background sim thread is blocked") + fun edtRemainsResponsiveDuringSimulation() { + // SIM_BLOCK_TIMEOUT_S is chosen to fit within the @Timeout(30s) budget. + val simBlockTimeoutS = 20L + val latchAwaitTimeoutS = 5L + + val simStarted = CountDownLatch(1) + val stopSim = CountDownLatch(1) + val mockCtx = mockk(relaxed = true) + every { mockCtx.run() } answers { simStarted.countDown(); stopSim.await(simBlockTimeoutS, TimeUnit.SECONDS) } + + val runner = SimulationRunner(mockCtx) + runner.speedMultiplier = 100.0 + runner.start() + assertThat(simStarted.await(latchAwaitTimeoutS, TimeUnit.SECONDS)).isTrue() + + // Warm up EDT to ensure it is actively processing events before measurement + val warmup = CountDownLatch(1) + SwingUtilities.invokeLater { warmup.countDown() } + assertThat(warmup.await(latchAwaitTimeoutS, TimeUnit.SECONDS)).isTrue() + + // Measure EDT dispatch latency with simulation thread running in background + val edtDone = CountDownLatch(1) + val startNs = System.nanoTime() + SwingUtilities.invokeLater { edtDone.countDown() } + assertThat(edtDone.await(latchAwaitTimeoutS, TimeUnit.SECONDS)).isTrue() + val latencyMs = (System.nanoTime() - startNs) / 1_000_000 + + logger.info { "EDT invokeLater latency (sim thread blocked): ${latencyMs}ms (target: < 100ms)" } + // CI-safe bound: 500ms. Real responsiveness target documented in class KDoc: 100ms. + assertThat(latencyMs).isLessThan(500) + + stopSim.countDown() + runner.stop() + } +} diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/StatusBarTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/StatusBarTest.kt index 4a01f3705..5d7490049 100644 --- a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/StatusBarTest.kt +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/StatusBarTest.kt @@ -14,7 +14,10 @@ package cz.vutbr.fit.interlockSim.gui import assertk.assertThat import assertk.assertions.contains import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue import cz.vutbr.fit.interlockSim.PROGRAM_NAME +import cz.vutbr.fit.interlockSim.objects.core.ContextChangeEvent import cz.vutbr.fit.interlockSim.testutil.KoinTestBase import cz.vutbr.fit.interlockSim.testutil.testModuleFull import io.mockk.mockk @@ -25,7 +28,9 @@ import org.junit.jupiter.api.Test import org.koin.core.module.Module import java.awt.Component import java.awt.event.MouseEvent -import cz.vutbr.fit.interlockSim.objects.core.ContextChangeEvent +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import javax.swing.SwingUtilities /** * Tests for StatusBar GUI component. @@ -121,43 +126,101 @@ class StatusBarTest : KoinTestBase() { @Test @DisplayName("handles property change with CharSequence value") fun handlesPropertyChangeWithCharSequenceValue() { - // Create property change event with CharSequence val event = ContextChangeEvent("status", "old", "New status message") - - // Trigger property change statusBar.propertyChange(event) - - // Verify text was updated + flushEDT() assertThat(statusBar.text).isEqualTo("New status message") } @Test @DisplayName("handles property change with non-CharSequence value") fun handlesPropertyChangeWithNonCharSequenceValue() { - // Create property change event with Integer val event = ContextChangeEvent("status", null, 12345) - - // Trigger property change statusBar.propertyChange(event) - - // Verify text was updated with toString() + flushEDT() assertThat(statusBar.text).isEqualTo("12345") } @Test @DisplayName("handles property change with null value") fun handlesPropertyChangeWithNullValue() { - // Set initial text statusBar.text = "Initial text" - - // Create property change event with null new value val event = ContextChangeEvent("status", "old", null) + statusBar.propertyChange(event) + flushEDT() + assertThat(statusBar.text).isEqualTo("Initial text") + } + + @Test + @DisplayName("updateSpeedIndicator shows speed text when multiplier is not 1.0x") + fun updateSpeedIndicatorShowsSpeedText() { + // updateSpeedIndicator uses invokeIfNeeded; flush is still needed when not on EDT + statusBar.updateSpeedIndicator(2.0) + flushEDT() + + assertThat(statusBar.speedIndicatorText()).isEqualTo("Speed: 2.0x") + assertThat(statusBar.isSpeedIndicatorVisible()).isTrue() + // Main status text must NOT be affected + assertThat(statusBar.text).contains(PROGRAM_NAME) + } + + @Test + @DisplayName("updateSpeedIndicator hides speed indicator at default speed (1.0x)") + fun updateSpeedIndicatorHidesAtDefaultSpeed() { + // First show at non-default speed, then reset + statusBar.updateSpeedIndicator(3.0) + flushEDT() + + statusBar.updateSpeedIndicator(1.0) + flushEDT() + + assertThat(statusBar.isSpeedIndicatorVisible()).isFalse() + // Stale text must be cleared so it cannot reappear later + assertThat(statusBar.speedIndicatorText()).isEqualTo("") + } + + @Test + @DisplayName("propertyChange from non-EDT thread defers label update via invokeLater") + fun propertyChangeFromNonEdtDefersUpdate() { + assertThat(SwingUtilities.isEventDispatchThread()).isFalse() + val initialText = statusBar.text - // Trigger property change + // Block the EDT so the invokeLater posted by propertyChange cannot execute yet. + val edtBlocker = CountDownLatch(1) + SwingUtilities.invokeLater { edtBlocker.await(5, TimeUnit.SECONDS) } + + val event = ContextChangeEvent("status", null, "background update") statusBar.propertyChange(event) - // Verify text remains unchanged when new value is null - assertThat(statusBar.text).isEqualTo("Initial text") + // EDT is blocked → invokeLater is queued but cannot execute → label holds old text. + assertThat(statusBar.text).isEqualTo(initialText) + + // Release EDT, then flush so the queued update runs. + edtBlocker.countDown() + flushEDT() + assertThat(statusBar.text).isEqualTo("background update") + } + + @Test + @DisplayName("updateSpeedIndicator formats multiplier with Locale.ROOT (dot separator)") + fun updateSpeedIndicatorFormatsMultiplier() { + statusBar.updateSpeedIndicator(0.5) + flushEDT() + + // Must use '.' decimal separator regardless of JVM default locale + assertThat(statusBar.speedIndicatorText()).isEqualTo("Speed: 0.5x") + } + + /** + * Flushes the EDT queue by calling [SwingUtilities.invokeAndWait] twice. + * + * Two flushes are needed when code under test uses [SwingUtilities.invokeLater]: + * the first flush lets the invokeLater task land on EDT, and the second flush + * runs it. + */ + private fun flushEDT() { + SwingUtilities.invokeAndWait { /* flush 1 */ } + SwingUtilities.invokeAndWait { /* flush 2 */ } } /** diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/ToolBarTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/ToolBarTest.kt index ebdcd1a84..f33364412 100644 --- a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/ToolBarTest.kt +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/ToolBarTest.kt @@ -209,6 +209,53 @@ class ToolBarTest : AbstractFrameTestBase() { } } + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("showSimulationControls adds label to toolbar") + fun showSimulationControlsAddsLabelToToolbar() { + runOnEDT { + val countBefore = toolBar.componentCount + toolBar.showSimulationControls() + // A separator and a label are added + assertThat(toolBar.componentCount).isEqualTo(countBefore + 2) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("hideSimulationControls removes label from toolbar") + fun hideSimulationControlsRemovesLabel() { + runOnEDT { + toolBar.showSimulationControls() + val countAfterShow = toolBar.componentCount + toolBar.hideSimulationControls() + assertThat(toolBar.componentCount).isEqualTo(countAfterShow - 2) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("showSimulationControls is idempotent") + fun showSimulationControlsIsIdempotent() { + runOnEDT { + toolBar.showSimulationControls() + val countAfterFirst = toolBar.componentCount + toolBar.showSimulationControls() // second call must be a no-op + assertThat(toolBar.componentCount).isEqualTo(countAfterFirst) + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + @DisplayName("hideSimulationControls is idempotent") + fun hideSimulationControlsIsIdempotent() { + runOnEDT { + val countBefore = toolBar.componentCount + toolBar.hideSimulationControls() // not shown — must be a no-op + assertThat(toolBar.componentCount).isEqualTo(countBefore) + } + } + @Test @Timeout(value = 5, unit = TimeUnit.SECONDS) @DisplayName("toolbar maintains state across multiple grid toggles") @@ -216,18 +263,18 @@ class ToolBarTest : AbstractFrameTestBase() { runOnEDT { val canvas = frame.railwayNetGridCanvas val initialState = canvas.isShowGrid() - + // Find grid toggle button val toggleButtons = toolBar.components.filterIsInstance() val gridToggleButton = toggleButtons.last() - + // Toggle multiple times gridToggleButton.doClick() assertThat(canvas.isShowGrid()).isEqualTo(!initialState) - + gridToggleButton.doClick() assertThat(canvas.isShowGrid()).isEqualTo(initialState) - + gridToggleButton.doClick() assertThat(canvas.isShowGrid()).isEqualTo(!initialState) } diff --git a/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/animation/ControlPanelTest.kt b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/animation/ControlPanelTest.kt new file mode 100644 index 000000000..26a4dc13d --- /dev/null +++ b/desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/animation/ControlPanelTest.kt @@ -0,0 +1,167 @@ +/* + Brno University of Technology + Faculty of Information Technology + + BSc Thesis 2006/2007 + Railway Interlocking Simulator + + Unit tests for ControlPanel (Issue #189) +*/ + +package cz.vutbr.fit.interlockSim.gui.animation + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.SwingUtilities + +/** + * Unit tests for [ControlPanel]. + * + * [ControlPanel] is a [javax.swing.JPanel] subclass and can be instantiated in + * headless environments (no X11 display required). All state-mutation tests drive + * it via its public API and inspect the child components directly. + * + * Covers: + * - Stop button disabled on construction + * - [setStopEnabled] enables and disables the Stop button + * - [updateStatus] changes the status label text + * - [onStop] callback wired and invoked when Stop button is clicked + * - [onStop] = null removes callback (no exception on click) + * - Status label initial value is "Status: Ready" + */ +@DisplayName("ControlPanel") +class ControlPanelTest { + private lateinit var panel: ControlPanel + + @BeforeEach + fun setUp() { + SwingUtilities.invokeAndWait { + panel = ControlPanel() + } + } + + // ── Stop button initial state ───────────────────────────────────────────── + + @Test + @DisplayName("stop button is disabled on construction") + fun stopButtonDisabledOnConstruction() { + SwingUtilities.invokeAndWait { + assertThat(findStopButton()!!.isEnabled).isFalse() + } + } + + // ── setStopEnabled ──────────────────────────────────────────────────────── + + @Test + @DisplayName("setStopEnabled(true) enables the stop button") + fun setStopEnabledTrue() { + SwingUtilities.invokeAndWait { + panel.setStopEnabled(true) + assertThat(findStopButton()!!.isEnabled).isTrue() + } + } + + @Test + @DisplayName("setStopEnabled(false) disables the stop button") + fun setStopEnabledFalse() { + SwingUtilities.invokeAndWait { + panel.setStopEnabled(true) + panel.setStopEnabled(false) + assertThat(findStopButton()!!.isEnabled).isFalse() + } + } + + // ── updateStatus ────────────────────────────────────────────────────────── + + @Test + @DisplayName("status label initial value is 'Status: Ready'") + fun statusLabelInitialValue() { + SwingUtilities.invokeAndWait { + assertThat(findStatusLabel()!!.text).isEqualTo("Status: Ready") + } + } + + @Test + @DisplayName("updateStatus changes the status label") + fun updateStatusChangesLabel() { + SwingUtilities.invokeAndWait { + panel.updateStatus(ControlPanel.SimulationStatus.RUNNING) + assertThat(findStatusLabel()!!.text).isEqualTo("Status: Running") + } + } + + @Test + @DisplayName("updateStatus to Stopped reflects in label") + fun updateStatusToStopped() { + SwingUtilities.invokeAndWait { + panel.updateStatus(ControlPanel.SimulationStatus.STOPPED) + assertThat(findStatusLabel()!!.text).isEqualTo("Status: Stopped") + } + } + + // ── onStop callback ─────────────────────────────────────────────────────── + + @Test + @DisplayName("onStop is null by default") + fun onStopNullByDefault() { + assertThat(panel.onStop).isNull() + } + + @Test + @DisplayName("onStop can be set to a non-null callback") + fun onStopCanBeSet() { + panel.onStop = { /* no-op */ } + assertThat(panel.onStop).isNotNull() + } + + @Test + @DisplayName("clicking stop button invokes onStop callback") + fun clickingStopInvokesCallback() { + var invoked = false + SwingUtilities.invokeAndWait { + panel.onStop = { invoked = true } + panel.setStopEnabled(true) + findStopButton()!!.doClick() + } + assertThat(invoked).isTrue() + } + + @Test + @DisplayName("clicking stop button when onStop is null does not throw") + fun clickingStopWithNullCallbackIsNoOp() { + SwingUtilities.invokeAndWait { + panel.onStop = null + panel.setStopEnabled(true) + findStopButton()!!.doClick() // must not throw + } + } + + @Test + @DisplayName("setting onStop to null clears callback") + fun settingOnStopToNullClearsCallback() { + panel.onStop = { /* no-op */ } + panel.onStop = null + assertThat(panel.onStop).isNull() + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private fun findStopButton(): JButton? = + (0 until panel.componentCount) + .mapNotNull { panel.getComponent(it) as? JButton } + .firstOrNull { it.text == "Stop" } + + private fun findStatusLabel(): JLabel? = + (0 until panel.componentCount) + .mapNotNull { panel.getComponent(it) as? JLabel } + .firstOrNull { it.text.startsWith("Status:") } +} diff --git a/docs/SIMULATION_SPEED_CONTROL.md b/docs/SIMULATION_SPEED_CONTROL.md new file mode 100644 index 000000000..addc53e3e --- /dev/null +++ b/docs/SIMULATION_SPEED_CONTROL.md @@ -0,0 +1,124 @@ +# Simulation Speed Control + +The animated simulation GUI supports live wall-clock speed control. + +The control changes how fast events are presented to the user. It does **not** change simulation semantics, event ordering, or physics calculations. + +## Overview + +Use speed control when you want to: + +- slow the model down for teaching or demos +- run near real time while watching train movement +- fast-forward through long scenarios +- pause temporarily while preparing for deeper Goal 8 debugging workflows + +Supported range: + +- **0.1x** minimum speed +- **50x** highest one-click preset +- **100x** absolute runner limit + +## Quick Start + +Start the animated GUI: + +```bash +./gradlew runExampleGui +``` + +Or run the JAR directly after building: + +```bash +java -jar build/libs/interlockSim.jar exampleGui shuntingLoop 300 +``` + +For your own XML file, open the desktop UI and use **Simulation → Start...**. + +When simulation mode is active, the GUI shows: + +- the animated control panel (`Time`, `Status`, `Stop`) +- the speed control panel (`Speed` slider, presets, live speed label) +- the status bar speed indicator when speed is not `1.0x` + +## GUI Controls + +### Speed slider + +- Range: **0.1x to 10.0x** +- Step: **0.1x** +- Best for fine adjustment while the simulation is already running + +### Preset buttons + +The panel and **Simulation → Speed** menu provide these presets: + +- `0.1x` +- `0.5x` +- `1x` +- `2x` +- `5x` +- `10x` +- `50x` + +`50x` is intentionally above the slider range. The slider stays clamped at its maximum visual position while the runner continues at `50x`. + +### Status indicator + +The status bar shows `Speed: X.Xx` whenever the current speed differs from `1.0x`. At the default speed the indicator is hidden to keep the bar uncluttered. + +![Simulation speed control panel](images/simulation-speed-control-panel.png) + +![Simulation speed status indicator](images/simulation-speed-status-indicator.png) + +## Keyboard Shortcuts + +Shortcuts are active in **simulation mode** while the application window has focus. + +| Shortcut | Action | +| --- | --- | +| `1` | Set speed to `0.5x` | +| `2` | Set speed to `1x` | +| `3` | Set speed to `2x` | +| `4` | Set speed to `5x` | +| `5` | Set speed to `10x` | +| `+` | Increase speed by `×1.5` | +| `-` | Decrease speed by `÷1.5` | +| `Space` | Pause or resume the active simulation | + +Notes: + +- On many keyboards, `+` requires **Shift+`=`**. +- Numpad `+` and `-` are also supported. + +## Common Use Cases + +- **Educational demo:** start at `0.5x` or `1x`, then drop to `0.1x` before a switch or semaphore change. +- **Normal observation:** keep the model at `1x` or `2x` and watch the event timeline. +- **Long scenario review:** jump to `10x` or `50x` to move through uneventful periods quickly. +- **Pre-debug pause workflow:** press `Space`, inspect the current state, then resume. + +## Technical Details + +- Speed control is implemented by `SimulationRunner`. +- The runner wraps `SimulationContext.run()` on a dedicated simulation thread. +- Wall-clock throttling uses `sleep(simDelta / speedMultiplier)`. +- `SimulationController` owns lifecycle, remembers the desired speed, and reapplies it on the next run. +- Swing updates stay on the EDT; simulation execution stays off the EDT. +- Pause blocks the simulation thread without advancing simulation time. + +## Limitations + +- The slider covers **0.1x to 10x**; preset buttons and the **Simulation → Speed** menu extend this to **50x**. +- To reach values above `50x`, press `+` repeatedly — the runner caps at **100x**. +- Speeds above roughly **10x** are useful for throughput, but animation becomes progressively harder to follow visually. +- At **50x** and above, small simulation deltas often round down to **0 ms sleep**, so execution becomes effectively CPU-bound — expect higher CPU usage and less smooth animation than at `1x`. +- Console-mode runs do not expose the GUI speed controls. + +## Troubleshooting + +- **I cannot see the controls:** make sure you started the animated GUI, not the console example. +- **The speed indicator disappeared:** that is expected at `1.0x`. +- **`Space` does nothing:** a simulation must be running and the window must have keyboard focus. Click anywhere in the window to regain focus, then try again. +- **The simulation feels too fast to follow:** use a preset such as `1x`, `0.5x`, or `0.1x`. +- **CPU usage is high at very high speeds:** reduce speed to re-enable more wall-clock throttling. diff --git a/docs/images/simulation-speed-control-panel.png b/docs/images/simulation-speed-control-panel.png new file mode 100644 index 000000000..35115115f Binary files /dev/null and b/docs/images/simulation-speed-control-panel.png differ diff --git a/docs/images/simulation-speed-status-indicator.png b/docs/images/simulation-speed-status-indicator.png new file mode 100644 index 000000000..c27aacd42 Binary files /dev/null and b/docs/images/simulation-speed-status-indicator.png differ