Skip to content

Commit 110b5aa

Browse files
CopilotbedaHovorkaclaude
authored
Refactor SimulationController to callback-based lifecycle API and move EDT UI updates into Frame (#488)
* refactor: decouple SimulationController from Swing components Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/01419796-a691-46e1-ae10-8c1373a9ee18 Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> * test: adapt SimulationController tests to callback-based UI wiring Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/01419796-a691-46e1-ae10-8c1373a9ee18 Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> * fix: handle stopped runner detachment and initial speed callback Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/e88ff78a-7a85-4ecd-9542-da0b8e356164 Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> * fix: resolve four Important issues from code review - StatusBar.propertyChange: dispatch label update via invokeLater when called from non-EDT thread (EDT violation now always reachable since StatusBar stays visible during simulation) - SimulationControlPanel: add onSpeedChanged callback wired to SimulationController.setSpeed() so desiredSpeed stays in sync; speed selected via slider/preset is now honoured after stop+start - Frame.setContext: close previous SimulationContext after new wiring to prevent Koin scope leak on repeated Simulation > Start... invocations - SimulationControllerTest: replace Thread.sleep(100) with flushEDT(times=3) to eliminate timing-dependent flakiness in stale-monitor test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> Co-authored-by: Bedrich Hovorka <bedrich.hovorka@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3f30b97 commit 110b5aa

9 files changed

Lines changed: 293 additions & 102 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ class MockSimulationContext(
4848
private var stopped: Boolean = false
4949
private var runListeners: List<ContextPropertyChangeListener> = emptyList()
5050

51+
/** Number of times [close] has been called. Used by tests to verify scope cleanup. */
52+
var closeCount: Int = 0
53+
private set
54+
5155
/**
5256
* On-demand cache for [DynamicTrack] wrappers.
5357
*
@@ -104,6 +108,10 @@ class MockSimulationContext(
104108
runListeners.forEach { it.propertyChange(event) }
105109
}
106110

111+
override fun close() {
112+
closeCount++
113+
}
114+
107115
override fun stop() {
108116
stopped = true
109117
}

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import javax.swing.JFrame
2626
import javax.swing.JOptionPane
2727
import javax.swing.JPanel
2828
import javax.swing.JScrollPane
29+
import javax.swing.SwingUtilities
2930
import javax.swing.Timer
3031

3132
/**
@@ -113,7 +114,30 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
113114
}
114115

115116
// Simulation lifecycle delegated to SimulationController for testability (Issue #189)
116-
internal val simulationController: SimulationController = SimulationController(controlPanel, toolBar, statusBar)
117+
internal val simulationController: SimulationController =
118+
SimulationController(
119+
onStateChanged = { state ->
120+
runOnEdt {
121+
when (state) {
122+
SimulationController.SimulationStatus.RUNNING -> {
123+
toolBar.showSimulationControls()
124+
controlPanel.updateStatus(ControlPanel.SimulationStatus.RUNNING)
125+
controlPanel.setStopEnabled(true)
126+
}
127+
128+
SimulationController.SimulationStatus.STOPPED -> {
129+
toolBar.hideSimulationControls()
130+
simulationControlPanel.runner = null
131+
controlPanel.setStopEnabled(false)
132+
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
133+
}
134+
}
135+
}
136+
},
137+
onSpeedChanged = { speed ->
138+
runOnEdt { statusBar.updateSpeedIndicator(speed) }
139+
}
140+
)
117141
private var currentSimulationContext: SimulationContext? = null
118142

119143
/**
@@ -141,6 +165,10 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
141165
northContainer.add(simulationControlPanel)
142166
contentPane.add(northContainer, BorderLayout.NORTH)
143167

168+
// Route speed changes from SimulationControlPanel through SimulationController so
169+
// desiredSpeed stays in sync and is applied to the next simulation start.
170+
simulationControlPanel.onSpeedChanged = { speed -> simulationController.setSpeed(speed) }
171+
144172
// South panel contains StatusBar (edit mode) and EventTimelinePanel (simulation mode)
145173
statusBar.registerProducer(railwayNetGridCanvas)
146174
southPanel.add(statusBar)
@@ -256,6 +284,8 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
256284
stopSimulation() // Stop any running simulation before switching context
257285
stopAnimationUpdates() // Cleanup existing timer
258286

287+
val previousSimulationContext = currentSimulationContext
288+
259289
when (context) {
260290
is SimulationContext -> {
261291
currentSimulationContext = context
@@ -291,6 +321,8 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
291321
}
292322

293323
context.addPropertyChangeListener(statusBar)
324+
325+
previousSimulationContext?.close()
294326
}
295327

296328
/**
@@ -397,6 +429,20 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
397429
}
398430
}
399431

432+
/**
433+
* Execute [action] on EDT.
434+
*
435+
* Runs immediately if already on EDT; otherwise schedules asynchronously via
436+
* [javax.swing.SwingUtilities.invokeLater] to avoid blocking monitor/background threads.
437+
*/
438+
private fun runOnEdt(action: () -> Unit) {
439+
if (SwingUtilities.isEventDispatchThread()) {
440+
action()
441+
} else {
442+
SwingUtilities.invokeLater(action)
443+
}
444+
}
445+
400446
/**
401447
* Handles window closing event.
402448
* Shows confirmation dialog if there are unsaved changes.

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ class SimulationControlPanel : JPanel() {
8686
}
8787
}
8888

89+
/**
90+
* Optional callback invoked whenever the user changes the speed (slider or preset button).
91+
*
92+
* Wire this in [cz.vutbr.fit.interlockSim.gui.Frame] to route speed changes through
93+
* [SimulationController.setSpeed] so that [SimulationController.desiredSpeed] stays in sync
94+
* and is honoured on the next [SimulationController.start] call.
95+
*
96+
* Not called when the runner fires a [SimulationRunner.PROP_SPEED_MULTIPLIER] event (i.e.
97+
* when the UI is updated from the runner rather than by the user).
98+
*/
99+
var onSpeedChanged: ((Double) -> Unit)? = null
100+
89101
/** Flag to suppress recursive slider → runner → slider feedback loops. */
90102
private var updatingFromRunner = false
91103

@@ -110,6 +122,7 @@ class SimulationControlPanel : JPanel() {
110122
val speed = sliderToSpeed(slider.value)
111123
speedLabel.text = formatSpeedLabel(speed)
112124
runner?.speedMultiplier = speed
125+
onSpeedChanged?.invoke(speed)
113126
}
114127
}
115128
sliderRow.add(slider)
@@ -141,10 +154,11 @@ class SimulationControlPanel : JPanel() {
141154
private fun speedToSlider(speed: Double): Int =
142155
Math.round(speed * SLIDER_SCALE).toInt().coerceIn(SLIDER_MIN, SLIDER_MAX)
143156

144-
/** Apply a preset speed: update runner, slider, and label. */
157+
/** Apply a preset speed: update runner, slider, label, and notify controller. */
145158
private fun applyPreset(speed: Double) {
146159
syncUiToSpeed(speed)
147160
runner?.speedMultiplier = speed
161+
onSpeedChanged?.invoke(speed)
148162
}
149163

150164
/**

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

Lines changed: 41 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@
1111
package cz.vutbr.fit.interlockSim.gui
1212

1313
import cz.vutbr.fit.interlockSim.context.SimulationContext
14-
import cz.vutbr.fit.interlockSim.gui.animation.ControlPanel
1514
import io.github.oshai.kotlinlogging.KotlinLogging
1615
import java.beans.PropertyChangeListener
17-
import javax.swing.SwingUtilities
1816

1917
/**
2018
* Manages the simulation lifecycle on behalf of [Frame] (Issue #189).
@@ -27,30 +25,29 @@ import javax.swing.SwingUtilities
2725
* - Starting the runner synchronously (before the monitor thread) to prevent the
2826
* race condition where [stop] is called before the monitor thread starts the runner
2927
* - Polling for completion on a daemon "SimulationMonitor" thread
30-
* - Enabling/disabling the Stop button in [ControlPanel] as the lifecycle changes
31-
* - Dispatching [onCompleted] back to EDT when the simulation finishes naturally
28+
* - Reporting lifecycle and speed changes via callbacks
3229
*
3330
* ## Thread Safety
34-
* - [start] and [stop] are designed to be called from the same thread (typically EDT
35-
* in production, but also from test threads in unit tests). They are NOT thread-safe
36-
* for concurrent calls from different threads; external callers are responsible for
37-
* serialization. [Frame] enforces EDT-only access via its own `require()` guards.
31+
* - [start] and [stop] are designed to be called from the same thread. They are NOT
32+
* thread-safe for concurrent calls from different threads; external callers are
33+
* responsible for serialization.
3834
* - [runner] is `@Volatile` so the monitor thread reads a fresh value when [stop]
3935
* nulls it.
40-
* - [onCompleted] is always dispatched to EDT via [SwingUtilities.invokeLater].
36+
* - Callbacks are invoked on whichever thread performs the lifecycle change.
4137
*
42-
* @param controlPanel ControlPanel whose Stop button and status label are managed here.
43-
* @param toolBar Optional [ToolBar] to show/hide simulation controls on lifecycle changes.
44-
* @param statusBar Optional [StatusBar] to display speed indicator when speed != 1.0x.
45-
* @param onCompleted Callback invoked on EDT when the simulation finishes naturally.
46-
* Defaults to a no-op if not provided.
38+
* @param onStateChanged Callback for lifecycle state updates. Invoked on the same
39+
* thread that performs the state change (caller thread for [start]/[stop], monitor
40+
* thread for natural completion).
41+
* @param onSpeedChanged Callback for speed indicator updates. Invoked on the thread
42+
* that emits the speed change; callers are responsible for EDT marshalling as needed.
43+
* @param onCompleted Callback invoked when the simulation finishes naturally on the
44+
* monitor thread. Defaults to a no-op.
4745
* @since 2026-04-20 (extracted from Frame for testability)
4846
* @see Frame
4947
*/
5048
internal class SimulationController(
51-
private val controlPanel: ControlPanel,
52-
private val toolBar: ToolBar? = null,
53-
private val statusBar: StatusBar? = null,
49+
private val onStateChanged: (SimulationStatus) -> Unit = {},
50+
private val onSpeedChanged: (Double) -> Unit = {},
5451
private val onCompleted: () -> Unit = {},
5552
) {
5653
/**
@@ -82,9 +79,9 @@ internal class SimulationController(
8279
* 2. Calls [SimulationRunner.start] **synchronously** (before the monitor thread) to
8380
* eliminate the race condition where [stop] could be invoked before the monitor
8481
* thread has a chance to start the runner.
85-
* 3. Updates [ControlPanel] status to "Running" and enables the Stop button.
82+
* 3. Emits [SimulationStatus.RUNNING] via [onStateChanged].
8683
* 4. Launches a daemon "SimulationMonitor" thread that polls [SimulationRunner.isRunning]
87-
* and on completion dispatches [onCompleted] and resets the panel via EDT.
84+
* and on completion emits [SimulationStatus.STOPPED] and invokes [onCompleted].
8885
*
8986
* @param context The simulation context to run.
9087
*/
@@ -103,35 +100,31 @@ internal class SimulationController(
103100
// thread. This ensures stopSimulation() always has a live thread to interrupt.
104101
newRunner.start()
105102

106-
// Wire speed indicator: notify StatusBar whenever SimulationRunner speed changes.
103+
// Wire speed callback for SimulationRunner speed changes.
107104
// The listener is removed when the simulation stops (in stop() or monitor finally).
108105
val listener = PropertyChangeListener { evt ->
109106
val multiplier = evt.newValue as? Double
110107
if (multiplier == null) {
111108
logger.debug { "Ignoring unexpected ${SimulationRunner.PROP_SPEED_MULTIPLIER} value: ${evt.newValue}" }
112109
return@PropertyChangeListener
113110
}
114-
statusBar?.updateSpeedIndicator(multiplier)
111+
onSpeedChanged(multiplier)
115112
}
116113
speedListener = listener
117114
newRunner.addPropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, listener)
115+
onSpeedChanged(newRunner.speedMultiplier)
118116

119-
// Show simulation controls in ToolBar (EDT-safe: start() is called from EDT).
120-
toolBar?.showSimulationControls()
121-
122-
controlPanel.updateStatus(ControlPanel.SimulationStatus.RUNNING)
123-
controlPanel.setStopEnabled(true)
117+
onStateChanged(SimulationStatus.RUNNING)
124118

125119
launchMonitorThread(newRunner)
126120
}
127121

128122
/**
129123
* Launch a daemon "SimulationMonitor" thread that polls [newRunner] for completion
130-
* and dispatches panel reset and [onCompleted] to EDT when done.
124+
* and dispatches callback notifications when done.
131125
*
132-
* Guards against stale-monitor: the [SwingUtilities.invokeLater] callback checks
133-
* `runner === newRunner` before mutating state so that a stop+start cycle started
134-
* before the lambda fires cannot clobber the new run's panel state.
126+
* Guards against stale-monitor by checking `runner === newRunner` before mutating
127+
* state so that a stop+start cycle cannot clobber the new run's state.
135128
*/
136129
private fun launchMonitorThread(newRunner: SimulationRunner) {
137130
val monitorThread =
@@ -144,20 +137,15 @@ internal class SimulationController(
144137
} catch (e: InterruptedException) {
145138
Thread.currentThread().interrupt()
146139
} finally {
147-
SwingUtilities.invokeLater {
148-
// Guard against stale-monitor: if stop() + start(ctxB) ran on EDT
149-
// before this callback fired, runner has been replaced with a new
150-
// instance. Skip the reset to avoid clobbering the new run's panel
151-
// state (and avoid firing onCompleted for the old run).
152-
if (runner === newRunner) {
153-
cleanupSpeedListener(newRunner)
154-
runner = null
155-
toolBar?.hideSimulationControls()
156-
statusBar?.updateSpeedIndicator(SimulationRunner.DEFAULT_SPEED)
157-
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
158-
controlPanel.setStopEnabled(false)
159-
onCompleted()
160-
}
140+
// Guard against stale-monitor: if stop() + start(ctxB) ran before
141+
// this callback executes, runner has been replaced with a new instance.
142+
// Skip reset to avoid clobbering the new run's state.
143+
if (runner === newRunner) {
144+
cleanupSpeedListener(newRunner)
145+
runner = null
146+
onSpeedChanged(SimulationRunner.DEFAULT_SPEED)
147+
onStateChanged(SimulationStatus.STOPPED)
148+
onCompleted()
161149
}
162150
}
163151
},
@@ -168,7 +156,7 @@ internal class SimulationController(
168156
}
169157

170158
/**
171-
* Stop a running simulation and reset the [ControlPanel].
159+
* Stop a running simulation and emit [SimulationStatus.STOPPED].
172160
*
173161
* Safe to call when no simulation is running (no-op in that case).
174162
*/
@@ -177,10 +165,8 @@ internal class SimulationController(
177165
cleanupSpeedListener(r)
178166
r.stop()
179167
runner = null
180-
toolBar?.hideSimulationControls()
181-
statusBar?.updateSpeedIndicator(SimulationRunner.DEFAULT_SPEED)
182-
controlPanel.setStopEnabled(false)
183-
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
168+
onSpeedChanged(SimulationRunner.DEFAULT_SPEED)
169+
onStateChanged(SimulationStatus.STOPPED)
184170
}
185171

186172
/** Removes the speed [PropertyChangeListener] from [r] and clears the reference. */
@@ -215,4 +201,10 @@ internal class SimulationController(
215201
/** Poll interval (ms) for the monitor thread to detect simulation completion. */
216202
internal const val SIMULATION_POLL_INTERVAL_MS: Long = 500L
217203
}
204+
205+
/** Simulation lifecycle states emitted via [onStateChanged]. */
206+
enum class SimulationStatus {
207+
RUNNING,
208+
STOPPED,
209+
}
218210
}

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,12 @@ class StatusBar :
8686
}
8787

8888
override fun propertyChange(event: ContextChangeEvent) {
89-
val newValue = event.newValue
90-
when {
91-
newValue is CharSequence -> text = newValue.toString()
92-
newValue != null -> text = newValue.toString()
89+
val newValue = event.newValue ?: return
90+
val newText = newValue.toString()
91+
if (SwingUtilities.isEventDispatchThread()) {
92+
text = newText
93+
} else {
94+
SwingUtilities.invokeLater { text = newText }
9395
}
9496
}
9597

desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/FrameTest.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,4 +285,23 @@ class FrameTest : AbstractFrameTestBase() {
285285
simContext.close()
286286
editContext.close()
287287
}
288+
289+
@Test
290+
@Timeout(value = 5, unit = TimeUnit.SECONDS)
291+
@DisplayName("setContext closes previous SimulationContext when switching to a new one")
292+
fun setContextClosesPreviousSimulationContext() {
293+
val context1 = cz.vutbr.fit.interlockSim.testutil.createMockSimulationContext(
294+
cz.vutbr.fit.interlockSim.testutil.TestFixtures.loadShuntingXml()
295+
)
296+
val context2 = cz.vutbr.fit.interlockSim.testutil.createMockSimulationContext(
297+
cz.vutbr.fit.interlockSim.testutil.TestFixtures.loadShuntingXml()
298+
)
299+
runOnEDT {
300+
frame.setContext(context1)
301+
frame.setContext(context2)
302+
}
303+
assertThat(context1.closeCount).isEqualTo(1)
304+
runOnEDT { frame.stopSimulation() }
305+
context2.close()
306+
}
288307
}

0 commit comments

Comments
 (0)