Skip to content

Commit 2bf0eed

Browse files
CopilotbedaHovorka
andauthored
Changes before error encountered
Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/f4316065-c3a6-47a4-be98-31cf8f27dd5d Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com>
1 parent 92db2b3 commit 2bf0eed

6 files changed

Lines changed: 236 additions & 43 deletions

File tree

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,9 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
164164
/**
165165
* Switch UI layout to simulation mode (Issue #205).
166166
*
167-
* - Hides StatusBar (shown by speed indicator when speed != 1.0x)
168-
* - Shows EventTimelinePanel in south panel (if created)
167+
* - StatusBar remains visible (its speed indicator [StatusBar.updateSpeedIndicator] shows
168+
* non-default speeds; [StatusBar.statusLabel] continues to display simulation events)
169+
* - Adds EventTimelinePanel to south panel (if created)
169170
* - Shows ControlPanel
170171
* - Disables editing ToolBar
171172
*
@@ -176,8 +177,6 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
176177
"switchToSimulationMode must be called from EDT"
177178
}
178179

179-
// Hide StatusBar; speed indicator (updateSpeedIndicator) will show it when speed != 1.0x
180-
statusBar.isVisible = false
181180
// Add EventTimelinePanel before StatusBar (index 0 = top of south panel, above StatusBar)
182181
eventTimelinePanel?.let { panel ->
183182
if (panel.parent == null) {
@@ -201,8 +200,7 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
201200
/**
202201
* Switch UI layout to editing mode (Issue #205).
203202
*
204-
* - Shows StatusBar
205-
* - Hides EventTimelinePanel
203+
* - Removes EventTimelinePanel from south panel (StatusBar remains visible throughout)
206204
* - Hides ControlPanel
207205
* - Enables editing ToolBar
208206
*
@@ -213,11 +211,10 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
213211
"switchToEditingMode must be called from EDT"
214212
}
215213

216-
// Remove EventTimelinePanel from south panel; show StatusBar
214+
// Remove EventTimelinePanel from south panel (StatusBar stays visible always)
217215
eventTimelinePanel?.let { panel ->
218216
southPanel.remove(panel)
219217
}
220-
statusBar.isVisible = true
221218

222219
// Hide ControlPanel
223220
controlPanel.isVisible = false

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

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,34 @@ import cz.vutbr.fit.interlockSim.PROGRAM_NAME
1313
import cz.vutbr.fit.interlockSim.context.ContextChangeListener
1414
import cz.vutbr.fit.interlockSim.exceptions.requireValidState
1515
import cz.vutbr.fit.interlockSim.objects.core.ContextChangeEvent
16+
import java.awt.BorderLayout
1617
import java.awt.Component
1718
import java.awt.Dimension
1819
import java.awt.event.MouseEvent
1920
import java.awt.event.MouseMotionListener
21+
import java.util.Locale
2022
import javax.swing.JLabel
23+
import javax.swing.JPanel
2124
import javax.swing.SwingUtilities
2225
import kotlin.math.abs
2326

2427
/**
25-
* Status bar for displaying context information and mouse motion status
28+
* Status bar for displaying context information and mouse motion status.
29+
*
30+
* Implemented as a [JPanel] containing two labels:
31+
* - [statusLabel] (CENTER): shows context property-change messages and mouse-position info.
32+
* - [speedLabel] (EAST): shows the current simulation speed multiplier when it differs from
33+
* 1.0x; hidden at default speed. Written only from EDT via [updateSpeedIndicator].
34+
*
35+
* Separating the two labels avoids the conflict where simulation-thread property-change
36+
* callbacks (via [ContextChangeListener]) would otherwise overwrite the speed indicator text.
2637
*/
2738
class StatusBar :
28-
JLabel(),
39+
JPanel(),
2940
ContextChangeListener {
41+
private val statusLabel = JLabel()
42+
private val speedLabel = JLabel().apply { isVisible = false }
43+
3044
private val mouseListener =
3145
object : MouseMotionListener {
3246
override fun mouseDragged(e: MouseEvent) {
@@ -41,7 +55,17 @@ class StatusBar :
4155
}
4256
}
4357

58+
/** Delegates to [statusLabel], providing a backward-compatible text property. */
59+
var text: String
60+
get() = statusLabel.text ?: ""
61+
set(value) {
62+
statusLabel.text = value
63+
}
64+
4465
init {
66+
layout = BorderLayout()
67+
add(statusLabel, BorderLayout.CENTER)
68+
add(speedLabel, BorderLayout.EAST)
4569
preferredSize = Dimension(100, 25)
4670
text = "Welcome to " + PROGRAM_NAME
4771
}
@@ -93,26 +117,42 @@ class StatusBar :
93117
}
94118

95119
/**
96-
* Updates the speed indicator display.
120+
* Updates the speed indicator in [speedLabel] (separate from [statusLabel]).
97121
*
98-
* Shows speed information in the status bar when the multiplier differs from
99-
* real-time (1.0x). Hides the status bar when running at default speed.
122+
* When [multiplier] differs from 1.0x, [speedLabel] shows "Speed: X.Xx" and becomes
123+
* visible. At default speed (1.0x) the label is hidden and its text is cleared so
124+
* no stale speed string is shown if the status bar later becomes visible again.
100125
*
101-
* EDT-safe: uses [SwingUtilities.invokeLater] if called from a background thread.
126+
* This method is EDT-safe: it executes synchronously when already on the EDT (so
127+
* [SimulationController.stop] on the EDT takes effect before subsequent mode-switch
128+
* calls), and uses [SwingUtilities.invokeLater] when called from a background thread.
102129
*
103-
* @param multiplier Current speed multiplier from [cz.vutbr.fit.interlockSim.gui.SimulationRunner]
130+
* @param multiplier Current speed multiplier from [SimulationRunner]
104131
*/
105132
fun updateSpeedIndicator(multiplier: Double) {
106-
SwingUtilities.invokeLater {
107-
if (abs(multiplier - DEFAULT_SPEED) > SPEED_EPSILON) {
108-
text = "Speed: ${"%.1f".format(multiplier)}x"
109-
isVisible = true
110-
} else {
111-
isVisible = false
112-
}
133+
if (SwingUtilities.isEventDispatchThread()) {
134+
applySpeedIndicator(multiplier)
135+
} else {
136+
SwingUtilities.invokeLater { applySpeedIndicator(multiplier) }
113137
}
114138
}
115139

140+
private fun applySpeedIndicator(multiplier: Double) {
141+
if (abs(multiplier - DEFAULT_SPEED) > SPEED_EPSILON) {
142+
speedLabel.text = String.format(Locale.ROOT, "Speed: %.1fx", multiplier)
143+
speedLabel.isVisible = true
144+
} else {
145+
speedLabel.text = ""
146+
speedLabel.isVisible = false
147+
}
148+
}
149+
150+
/** Returns `true` when the speed indicator label is currently visible. */
151+
internal fun isSpeedIndicatorVisible(): Boolean = speedLabel.isVisible
152+
153+
/** Returns the current speed indicator text, or an empty string when hidden. */
154+
internal fun speedIndicatorText(): String = speedLabel.text ?: ""
155+
116156
companion object {
117157
private const val DEFAULT_SPEED = 1.0
118158
private const val SPEED_EPSILON = 0.001

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,4 +229,60 @@ class FrameTest : AbstractFrameTestBase() {
229229
assertThat(layout.getLayoutComponent(BorderLayout.SOUTH)).isNotNull()
230230
}
231231
}
232+
233+
@Test
234+
@Timeout(value = 5, unit = TimeUnit.SECONDS)
235+
@DisplayName("south panel has one component (StatusBar) in editing mode")
236+
fun southPanelHasOneComponentInEditingMode() {
237+
runOnEDT {
238+
// Default state after Frame construction is editing mode — only StatusBar in south panel
239+
val southPanel =
240+
(frame.contentPane.layout as BorderLayout)
241+
.getLayoutComponent(BorderLayout.SOUTH) as javax.swing.JPanel
242+
assertThat(southPanel.componentCount).isEqualTo(1)
243+
assertThat(southPanel.getComponent(0)).isInstanceOf(StatusBar::class)
244+
}
245+
}
246+
247+
@Test
248+
@Timeout(value = 5, unit = TimeUnit.SECONDS)
249+
@DisplayName("south panel gains EventTimelinePanel when switching to simulation mode")
250+
fun southPanelGainsTimelinePanelInSimulationMode() {
251+
val context = cz.vutbr.fit.interlockSim.testutil.createMockSimulationContext(
252+
cz.vutbr.fit.interlockSim.testutil.TestFixtures.loadShuntingXml()
253+
)
254+
runOnEDT {
255+
frame.setContext(context)
256+
// Simulation mode: EventTimelinePanel is added above StatusBar
257+
val southPanel =
258+
(frame.contentPane.layout as BorderLayout)
259+
.getLayoutComponent(BorderLayout.SOUTH) as javax.swing.JPanel
260+
assertThat(southPanel.componentCount).isEqualTo(2)
261+
}
262+
runOnEDT { frame.stopSimulation() }
263+
context.close()
264+
}
265+
266+
@Test
267+
@Timeout(value = 5, unit = TimeUnit.SECONDS)
268+
@DisplayName("south panel returns to one component when switching back to editing mode")
269+
fun southPanelRestoresOneComponentAfterSwitchingToEditingMode() {
270+
val simContext = cz.vutbr.fit.interlockSim.testutil.createMockSimulationContext(
271+
cz.vutbr.fit.interlockSim.testutil.TestFixtures.loadShuntingXml()
272+
)
273+
val editContext = editingContextFactory.createEmptyContext()
274+
runOnEDT {
275+
frame.setContext(simContext)
276+
// switch back to editing — EventTimelinePanel removed, only StatusBar remains
277+
frame.setContext(editContext)
278+
val southPanel =
279+
(frame.contentPane.layout as BorderLayout)
280+
.getLayoutComponent(BorderLayout.SOUTH) as javax.swing.JPanel
281+
assertThat(southPanel.componentCount).isEqualTo(1)
282+
// StatusBar must still be visible after returning to editing mode
283+
assertThat(frame.statusBar.isVisible).isTrue()
284+
}
285+
simContext.close()
286+
editContext.close()
287+
}
232288
}

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

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

1313
import assertk.assertThat
14+
import assertk.assertions.doesNotContain
1415
import assertk.assertions.isEqualTo
1516
import assertk.assertions.isFalse
1617
import assertk.assertions.isNotNull
@@ -449,7 +450,7 @@ class SimulationControllerTest {
449450

450451
@Test
451452
@Timeout(value = 10, unit = TimeUnit.SECONDS)
452-
@DisplayName("speed change on runner propagates to StatusBar indicator")
453+
@DisplayName("speed change on runner propagates to StatusBar speed indicator")
453454
fun speedChangePropagatesToStatusBar() {
454455
val started = CountDownLatch(1)
455456
val blockSim = CountDownLatch(1)
@@ -467,12 +468,14 @@ class SimulationControllerTest {
467468
// Change speed via runner — this fires PROP_SPEED_MULTIPLIER
468469
controller.runner!!.speedMultiplier = 2.0
469470

470-
// Flush EDT twice (listener calls invokeLater, so two flushes are needed)
471+
// Flush EDT twice (listener calls invokeLater when not on EDT)
471472
flushEDT()
472473

473474
SwingUtilities.invokeAndWait {
474-
assertThat(findSpeedText(statusBar)).isEqualTo("Speed: 2.0x")
475-
assertThat(statusBar.isVisible).isTrue()
475+
assertThat(statusBar.speedIndicatorText()).isEqualTo("Speed: 2.0x")
476+
assertThat(statusBar.isSpeedIndicatorVisible()).isTrue()
477+
// Status message text must NOT be overwritten by the speed indicator
478+
assertThat(statusBar.text).doesNotContain("Speed:")
476479
}
477480

478481
blockSim.countDown()
@@ -500,14 +503,62 @@ class SimulationControllerTest {
500503
controller.runner!!.speedMultiplier = 3.0
501504
flushEDT()
502505

506+
// stop() is called from test thread (not EDT), so invokeLater is used
503507
controller.stop()
504508
blockSim.countDown()
505-
506-
// Stop calls updateSpeedIndicator(DEFAULT_SPEED) via invokeLater
507509
flushEDT()
508510

509511
SwingUtilities.invokeAndWait {
510-
assertThat(statusBar.isVisible).isFalse()
512+
assertThat(statusBar.isSpeedIndicatorVisible()).isFalse()
513+
assertThat(statusBar.speedIndicatorText()).isEqualTo("")
514+
}
515+
}
516+
517+
@Test
518+
@Timeout(value = 10, unit = TimeUnit.SECONDS)
519+
@DisplayName("start shows simulation controls in ToolBar")
520+
fun startShowsToolBarSimulationControls() {
521+
val started = CountDownLatch(1)
522+
val blockSim = CountDownLatch(1)
523+
every { context.run() } answers {
524+
started.countDown()
525+
blockSim.await(10, TimeUnit.SECONDS)
526+
}
527+
528+
val toolBar = mockk<ToolBar>(relaxed = true)
529+
val controller = SimulationController(controlPanel, toolBar = toolBar)
530+
controller.start(context)
531+
assertThat(started.await(5, TimeUnit.SECONDS)).isTrue()
532+
533+
SwingUtilities.invokeAndWait {
534+
verify(exactly = 1) { toolBar.showSimulationControls() }
535+
}
536+
537+
blockSim.countDown()
538+
controller.stop()
539+
}
540+
541+
@Test
542+
@Timeout(value = 10, unit = TimeUnit.SECONDS)
543+
@DisplayName("stop hides simulation controls in ToolBar")
544+
fun stopHidesToolBarSimulationControls() {
545+
val started = CountDownLatch(1)
546+
val blockSim = CountDownLatch(1)
547+
every { context.run() } answers {
548+
started.countDown()
549+
blockSim.await(10, TimeUnit.SECONDS)
550+
}
551+
552+
val toolBar = mockk<ToolBar>(relaxed = true)
553+
val controller = SimulationController(controlPanel, toolBar = toolBar)
554+
controller.start(context)
555+
assertThat(started.await(5, TimeUnit.SECONDS)).isTrue()
556+
557+
controller.stop()
558+
blockSim.countDown()
559+
560+
SwingUtilities.invokeAndWait {
561+
verify(exactly = 1) { toolBar.hideSimulationControls() }
511562
}
512563
}
513564

@@ -524,9 +575,6 @@ class SimulationControllerTest {
524575
repeat(times) { SwingUtilities.invokeAndWait { /* flush */ } }
525576
}
526577

527-
private fun findSpeedText(statusBar: StatusBar): String? =
528-
statusBar.text.takeIf { it.startsWith("Speed:") }
529-
530578
private fun findStopButton(): JButton? =
531579
(0 until controlPanel.componentCount)
532580
.mapNotNull { controlPanel.getComponent(it) as? JButton }

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,18 @@ class StatusBarTest : KoinTestBase() {
166166
@Test
167167
@DisplayName("updateSpeedIndicator shows speed text when multiplier is not 1.0x")
168168
fun updateSpeedIndicatorShowsSpeedText() {
169-
// updateSpeedIndicator uses invokeLater; call from non-EDT and flush twice
169+
// updateSpeedIndicator uses invokeIfNeeded; flush is still needed when not on EDT
170170
statusBar.updateSpeedIndicator(2.0)
171171
flushEDT()
172172

173-
assertThat(statusBar.text).isEqualTo("Speed: 2.0x")
174-
assertThat(statusBar.isVisible).isTrue()
173+
assertThat(statusBar.speedIndicatorText()).isEqualTo("Speed: 2.0x")
174+
assertThat(statusBar.isSpeedIndicatorVisible()).isTrue()
175+
// Main status text must NOT be affected
176+
assertThat(statusBar.text).contains(PROGRAM_NAME)
175177
}
176178

177179
@Test
178-
@DisplayName("updateSpeedIndicator hides status bar at default speed (1.0x)")
180+
@DisplayName("updateSpeedIndicator hides speed indicator at default speed (1.0x)")
179181
fun updateSpeedIndicatorHidesAtDefaultSpeed() {
180182
// First show at non-default speed, then reset
181183
statusBar.updateSpeedIndicator(3.0)
@@ -184,16 +186,19 @@ class StatusBarTest : KoinTestBase() {
184186
statusBar.updateSpeedIndicator(1.0)
185187
flushEDT()
186188

187-
assertThat(statusBar.isVisible).isFalse()
189+
assertThat(statusBar.isSpeedIndicatorVisible()).isFalse()
190+
// Stale text must be cleared so it cannot reappear later
191+
assertThat(statusBar.speedIndicatorText()).isEqualTo("")
188192
}
189193

190194
@Test
191-
@DisplayName("updateSpeedIndicator formats multiplier to one decimal place")
195+
@DisplayName("updateSpeedIndicator formats multiplier with Locale.ROOT (dot separator)")
192196
fun updateSpeedIndicatorFormatsMultiplier() {
193197
statusBar.updateSpeedIndicator(0.5)
194198
flushEDT()
195199

196-
assertThat(statusBar.text).isEqualTo("Speed: 0.5x")
200+
// Must use '.' decimal separator regardless of JVM default locale
201+
assertThat(statusBar.speedIndicatorText()).isEqualTo("Speed: 0.5x")
197202
}
198203

199204
/**

0 commit comments

Comments
 (0)