Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9b5a1ca
Initial plan
Copilot Feb 6, 2026
a6d3f3d
Add GUI validation to prevent saving contexts with insufficient InOut…
Copilot Feb 6, 2026
64ecd3e
Add documentation for InOut validation implementation
Copilot Feb 6, 2026
5993297
Add visual mockup for InOut validation dialog
Copilot Feb 6, 2026
12e3257
Add final summary for Issue #80 implementation
Copilot Feb 6, 2026
b340147
Add README for issues documentation directory
Copilot Feb 6, 2026
cdd0baf
Fix Detekt static analysis errors: remove unused import and fix depre…
Copilot Feb 6, 2026
316fb82
Fix additional Detekt errors: replace deprecated constants with domai…
Copilot Feb 6, 2026
ffa5da1
Address PR #357 review feedback
bedaHovorka Mar 13, 2026
12e8af1
Address PR #357 inline review feedback
bedaHovorka Mar 21, 2026
d4097f9
fix(review): address Copilot review feedback on PR #357
bedaHovorka Mar 21, 2026
5c78146
Update docs/issues/issue_80.md
bedaHovorka Mar 27, 2026
81562f1
Update desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/MenuB…
bedaHovorka Mar 27, 2026
4770424
fix(gui): use singular form in OpenAction warning dialog to match MIN…
bedaHovorka Mar 27, 2026
9c97e59
test(gui): add edge-case tests for MenuBar.validateForSave (#357)
bedaHovorka Mar 27, 2026
cea6180
test(paths): add setUpSemaphores coverage via path reservation (#357)
bedaHovorka Mar 27, 2026
984edd0
test(paths): cover switch->semaphore branch in setUpSemaphores (#357)
bedaHovorka Mar 28, 2026
5a63613
fix(xsd): allow 0-based coordinates in SimpleTrackBlock (positiveInte…
bedaHovorka Mar 29, 2026
a45f4c4
chore: ignore test file aaa.xml
bedaHovorka Mar 29, 2026
ca34d22
chore: generalize .gitignore pattern for desktop-ui XML files
Copilot Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,4 @@ Thumbs.db

# Claude Code configuration (local settings and skills)
.claude/
desktop-ui/*.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ package cz.vutbr.fit.interlockSim.objects.paths

import cz.vutbr.fit.interlockSim.context.SimulationContext
import cz.vutbr.fit.interlockSim.context.SimulationContext.ReportType
import cz.vutbr.fit.interlockSim.domain.ABSOLUTE_MAX_SPEED
import cz.vutbr.fit.interlockSim.domain.MINIMAL_MAX_SPEED
import cz.vutbr.fit.interlockSim.exceptions.TrackOperationException
import cz.vutbr.fit.interlockSim.exceptions.requireSimulation
Expand Down Expand Up @@ -278,7 +279,7 @@ abstract class AbstractPath protected constructor(
// Semaphore element: configure with previous track and switch speed
if (previousTrack == null) continue
if (context.isSeparatorInDirection(element, previousTrack, null)) {
val speed = previousSwitch?.allowedSpeed() ?: PathElement.ABSOLUTE_MAX_SPEED
val speed = previousSwitch?.allowedSpeed() ?: ABSOLUTE_MAX_SPEED
val segment = context.getSegment(element, null, previousTrack)
val segment2 = context.getSegment(element, previousTrack, null)
element.setUpSpeed(segment, segment2, speed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,12 @@ object XmlSchemaContent {

<xs:element name="SimpleTrackBlock">
<xs:complexType>
<xs:attribute name="fromX" type="xs:positiveInteger"></xs:attribute>
<xs:attribute name="fromY" type="xs:positiveInteger"></xs:attribute>
<xs:attribute name="fromX" type="xs:nonNegativeInteger"></xs:attribute>
<xs:attribute name="fromY" type="xs:nonNegativeInteger"></xs:attribute>
<xs:attribute name="fromSegment" type="xs:string"></xs:attribute>
<xs:attribute name="toSegment" type="xs:string"></xs:attribute>
<xs:attribute name="toX" type="xs:positiveInteger"></xs:attribute>
<xs:attribute name="toY" type="xs:positiveInteger"></xs:attribute>
<xs:attribute name="toX" type="xs:nonNegativeInteger"></xs:attribute>
<xs:attribute name="toY" type="xs:nonNegativeInteger"></xs:attribute>
<xs:attribute name="length" type="xs:double"></xs:attribute>
<xs:attribute name="maxSpeedfrom" type="xs:double"></xs:attribute>
<xs:attribute name="maxSpeedto" type="xs:double"></xs:attribute>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ class XMLContextFactory : EditingContextFactory {
/**
* Saves an editing context to an output stream.
*
* Pre-save validation (Issue #XXX, PR #358):
* Pre-save validation (Issue #80, PR #357):
* - Validates InOut count before serialization
* - Prevents saving invalid contexts (< MIN_INOUT_ELEMENTS InOuts)
* - Returns false if validation fails
Expand Down Expand Up @@ -293,7 +293,7 @@ class XMLContextFactory : EditingContextFactory {
/**
* Saves an editing context to a file.
*
* Pre-save validation (Issue #XXX, PR #358):
* Pre-save validation (Issue #80, PR #357):
* - Validates InOut count before serialization
* - Prevents saving invalid contexts (< MIN_INOUT_ELEMENTS InOuts)
* - Returns false if validation fails
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@

<xs:element name="SimpleTrackBlock">
<xs:complexType>
<xs:attribute name="fromX" type="xs:positiveInteger"></xs:attribute>
<xs:attribute name="fromY" type="xs:positiveInteger"></xs:attribute>
<xs:attribute name="fromX" type="xs:nonNegativeInteger"></xs:attribute>
<xs:attribute name="fromY" type="xs:nonNegativeInteger"></xs:attribute>
<xs:attribute name="fromSegment" type="xs:string"></xs:attribute>
<xs:attribute name="toSegment" type="xs:string"></xs:attribute>
<xs:attribute name="toX" type="xs:positiveInteger"></xs:attribute>
<xs:attribute name="toY" type="xs:positiveInteger"></xs:attribute>
<xs:attribute name="toX" type="xs:nonNegativeInteger"></xs:attribute>
<xs:attribute name="toY" type="xs:nonNegativeInteger"></xs:attribute>
<xs:attribute name="length" type="xs:double"></xs:attribute>
<xs:attribute name="maxSpeedfrom" type="xs:double"></xs:attribute>
<xs:attribute name="maxSpeedto" type="xs:double"></xs:attribute>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/* Brno University of Technology
* Faculty of Information Technology
*
* BSc Thesis 2006/2007
*
* Railway Interlocking Simulator
*
* Integration test for AbstractPath.setUpSemaphores() coverage (Issue #357)
*/
package cz.vutbr.fit.interlockSim.objects.paths

import assertk.assertThat
import assertk.assertions.isGreaterThanOrEqualTo
import assertk.assertions.isTrue
import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext
import cz.vutbr.fit.interlockSim.context.EditingContext
import cz.vutbr.fit.interlockSim.context.EditingContextFactory
import cz.vutbr.fit.interlockSim.context.SimulationContextFactory
import cz.vutbr.fit.interlockSim.context.navigation.TopologyNavigator
import cz.vutbr.fit.interlockSim.objects.cells.DynamicRailSwitch
import cz.vutbr.fit.interlockSim.objects.core.DynamicPathSeparator
import cz.vutbr.fit.interlockSim.objects.core.OrientedPathSeparator
import cz.vutbr.fit.interlockSim.objects.core.Track
import cz.vutbr.fit.interlockSim.objects.core.TrackOccupant
import cz.vutbr.fit.interlockSim.objects.tracks.TrackSection
import cz.vutbr.fit.interlockSim.testutil.KoinTestBase
import cz.vutbr.fit.interlockSim.testutil.TestFixtures
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.test.inject
import java.util.concurrent.TimeUnit.SECONDS

/**
* Integration test that exercises [AbstractPath.setUpSemaphores] via path-level setUpPath().
*
* ## Purpose
*
* Covers both branches of line 282 of AbstractPath.kt:
* - Null branch: `previousSwitch == null` -> ABSOLUTE_MAX_SPEED (existing test)
* - Non-null branch: `previousSwitch?.allowedSpeed()` when a switch precedes a semaphore
*
* ## Approach
*
* 1. Load XML and create a SimulationContext
* 2. Build a full path from topology results
* 3. Configure switches in the path (same as PathReservationService would)
* 4. Call path.setUpPath(startSep, trainId) which triggers setUpSemaphores()
*
* ## Topologies
*
* ### vyhybna.xml (null branch)
* InOut_B(30,8) - track - zB(27,8) - track - vB(26,8) - track - doB1(25,8)
* ... middle tracks ...
* doA1(16,8) - track - vA(15,8) - track - zA(14,8) - track - InOut_A(11,8)
*
* ### switch-between-semaphores.xml (non-null branch)
* InOut_A(5,10) - track - semA(8,10) - track - sw1(12,10) - track - semB(15,10) - track - InOut_B(18,10)
* \- siding - InOut_C(18,11)
* Both semaphores have orientation=false (direction=F, facing toward higher X).
* Backward iteration from InOut_B encounters previousTrack on the F-segment side,
* so isSeparatorInDirection returns true for both semaphores:
* semB (in-direction, prevSwitch=null -> null branch), sw1 (prevSwitch=sw1),
* semA (in-direction, prevSwitch=sw1 -> non-null branch at line 282).
*
* @since Issue #357
*/
@DisplayName("AbstractPath.setUpSemaphores Coverage (Issue #357)")
@Tag("integration-test")
@Timeout(30, unit = SECONDS)
class AbstractPathSetUpSemaphoresTest : KoinTestBase() {
private val editingContextFactory: EditingContextFactory by inject()
private val simulationContextFactory: SimulationContextFactory by inject()

/**
* Minimal TrackOccupant for switch configuration (same pattern as
* DefaultPathReservationService.MinimalTrackOccupant which is private).
*/
private class TestTrackOccupant(
override val name: String
) : TrackOccupant {
override fun distanceToSemaphore(): Double = 0.0
override fun nextSemaphore(): OrientedPathSeparator? = null
}

@Test
@DisplayName("setUpSemaphores uses ABSOLUTE_MAX_SPEED when no preceding switch exists")
fun setUpSemaphoresUsesAbsoluteMaxSpeedWhenNoPrecedingSwitch() {
val xmlStream = TestFixtures.loadShuntingXml()
?: throw IllegalStateException("vyhybna.xml not found in resources")

val editingContext = editingContextFactory.createContext(xmlStream) as EditingContext
val simulationContext =
simulationContextFactory.createContext(editingContext) as DefaultSimulationContext

try {
// Step 1: Get topology navigator
val navigator: TopologyNavigator = simulationContext.scope.get()

// Step 2: Get InOut elements
val inOutsList = simulationContext.getInOuts().toList()
val inOut1 = simulationContext.toDynamic(inOutsList[0])
val inOut2 = simulationContext.toDynamic(inOutsList[1])

// Step 3: Get topology path (track sections) for InOut1 -> InOut2
val candidatePaths = navigator.findAllTopologicalPaths(inOut1, inOut2)
assertThat(candidatePaths.size).isGreaterThanOrEqualTo(1)
val trackSections: List<TrackSection> = candidatePaths[0]

// Step 4: Build an ArrayPath from topology results
// Pattern matches PathInfoBuilder.buildFullPath()
val path = ArrayPath(simulationContext)
path.add(inOut1)
var currentSeparator: DynamicPathSeparator = inOut1
for (trackSection in trackSections) {
path.add(trackSection)
val staticResult = trackSection.getSecondEnd(currentSeparator)
currentSeparator = simulationContext.toDynamic(staticResult)
path.add(currentSeparator)
}
assertThat(path.size).isGreaterThanOrEqualTo(3)

// Step 5: Configure switches in the path before calling setUpPath
// AbstractPath.separatorSetting checks getFollowingSegment(from) === to
// for SET_UP_PATH, so switches must be configured first.
val pathElements = path.toList()
val trainOccupant = TestTrackOccupant("test-train")
for ((index, element) in pathElements.withIndex()) {
if (element is DynamicRailSwitch) {
// Find previous and next tracks
var previous: Track? = null
for (i in (index - 1) downTo 0) {
if (pathElements[i] is Track) {
previous = pathElements[i] as Track
break
}
}
var next: Track? = null
for (i in (index + 1) until pathElements.size) {
if (pathElements[i] is Track) {
next = pathElements[i] as Track
break
}
}
if (next != null) {
val from = simulationContext.getSegment(element, previous, next)
val to = simulationContext.getSegment(element, next, previous)
element.setUpPath(from, to, element.allowedSpeed(), trainOccupant)
}
}
}

// Step 6: Call setUpPath which triggers setUpSemaphores()
// All blocks are FREE, switches are configured.
// setUpSemaphores() iterates backward from the other end and
// finds semaphore zA with no preceding switch, using
// ABSOLUTE_MAX_SPEED (line 282 of AbstractPath.kt).
path.setUpPath(inOut1, "test-train")

// Step 7: Verify the path was successfully set up
// isSetUpPath returns true when all tracks are reserved
val isSetUp = path.isSetUpPath(inOut1)
assertThat(isSetUp).isTrue()
} finally {
simulationContext.close()
}
}

@Test
@DisplayName("setUpSemaphores uses switch allowedSpeed when preceding switch exists")
fun setUpSemaphoresUsesSwitchSpeedWhenPrecedingSwitchExists() {
val xmlStream = TestFixtures.loadSwitchBetweenSemaphoresXml()

val editingContext = editingContextFactory.createContext(xmlStream) as EditingContext
val simulationContext =
simulationContextFactory.createContext(editingContext) as DefaultSimulationContext

try {
// Step 1: Get topology navigator
val navigator: TopologyNavigator = simulationContext.scope.get()

// Step 2: Get InOut elements - find A and B by name
val inOutsList = simulationContext.getInOuts().toList()
val inOutA = inOutsList.first { it.name == "A" }
val inOutB = inOutsList.first { it.name == "B" }

// Step 3: Get topology path (track sections) for A -> B
val candidatePaths = navigator.findAllTopologicalPaths(inOutA, inOutB)
assertThat(candidatePaths.size).isGreaterThanOrEqualTo(1)
val trackSections: List<TrackSection> = candidatePaths[0]

// Step 4: Build an ArrayPath from topology results
val path = ArrayPath(simulationContext)
path.add(inOutA)
var currentSeparator: DynamicPathSeparator = inOutA
for (trackSection in trackSections) {
path.add(trackSection)
val staticResult = trackSection.getSecondEnd(currentSeparator)
currentSeparator = simulationContext.toDynamic(staticResult)
path.add(currentSeparator)
}
assertThat(path.size).isGreaterThanOrEqualTo(3)

// Step 5: Configure switches in the path
val pathElements = path.toList()
val trainOccupant = TestTrackOccupant("test-train-switch")
for ((index, element) in pathElements.withIndex()) {
if (element is DynamicRailSwitch) {
var previous: Track? = null
for (i in (index - 1) downTo 0) {
if (pathElements[i] is Track) {
previous = pathElements[i] as Track
break
}
}
var next: Track? = null
for (i in (index + 1) until pathElements.size) {
if (pathElements[i] is Track) {
next = pathElements[i] as Track
break
}
}
if (next != null) {
val from = simulationContext.getSegment(element, previous, next)
val to = simulationContext.getSegment(element, next, previous)
element.setUpPath(from, to, element.allowedSpeed(), trainOccupant)
}
}
}

// Step 6: Call setUpPath which triggers setUpSemaphores()
// setUpSemaphores(inOutA) iterates backward from getSecondEnd(inOutA) = inOutB.
// Both semaphores have orientation=false -> direction()=F, matching the
// F-segment where previousTrack connects during backward iteration.
// Backward iteration: InOut_B, track, semB (direction=F, prevSwitch=null),
// track, sw1 (switch -> prevSwitch=sw1), track,
// semA (direction=F, prevSwitch=sw1 -> non-null branch at line 282)
path.setUpPath(inOutA, "test-train-switch")

// Step 7: Verify the path was successfully set up
val isSetUp = path.isSetUpPath(inOutA)
assertThat(isSetUp).isTrue()
} finally {
simulationContext.close()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ object TestFixtures {

fun loadInvalidNameTooLongXml(): InputStream = loadTestFixture("invalid-name-too-long.xml")

fun loadSwitchBetweenSemaphoresXml(): InputStream = loadTestFixture("switch-between-semaphores.xml")

fun loadInvalidInOutXml(fixtureName: String): InputStream = loadTestFixture(fixtureName)

private fun loadMainResource(resourceName: String): InputStream =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<!DOCTYPE net>
<net X="100" Y="100">
<InOut X="5" Y="10" SpatialType="HORIZONTAL" orientation="false" name="A"/>
<RailSemaphore X="8" Y="10" SpatialType="HORIZONTAL" orientation="false" name="semA"/>
<RailSwitch X="12" Y="10" SpatialType="HORIZONTAL" Type="SIMPLE_RIGHT_FALSE" name="sw1"/>
<RailSemaphore X="15" Y="10" SpatialType="HORIZONTAL" orientation="false" name="semB"/>
<InOut X="18" Y="10" SpatialType="HORIZONTAL" orientation="true" name="B"/>
<InOut X="18" Y="11" SpatialType="HORIZONTAL" orientation="true" name="C"/>
<SimpleTrackBlock fromX="5" fromY="10" toX="8" toY="10" fromSegment="F" toSegment="A" length="30.0" maxSpeedfrom="24.0" maxSpeedto="24.0"/>
<SimpleTrackBlock fromX="8" fromY="10" toX="12" toY="10" fromSegment="F" toSegment="A" length="40.0" maxSpeedfrom="24.0" maxSpeedto="24.0"/>
<SimpleTrackBlock fromX="12" fromY="10" toX="15" toY="10" fromSegment="F" toSegment="A" length="30.0" maxSpeedfrom="24.0" maxSpeedto="24.0"/>
<SimpleTrackBlock fromX="15" fromY="10" toX="18" toY="10" fromSegment="F" toSegment="A" length="30.0" maxSpeedfrom="24.0" maxSpeedto="24.0"/>
<SimpleTrackBlock fromX="12" fromY="10" toX="18" toY="11" fromSegment="G" toSegment="A" length="60.0" maxSpeedfrom="20.0" maxSpeedto="20.0"/>
</net>
Loading