Skip to content

Commit 644255c

Browse files
CopilotbedaHovorka
andcommitted
Introduce PathResult sealed class to distinguish permanent vs temporary path unavailability (#352)
* Update test files to use PathResult sealed class instead of nullable Path - Added PathResult import to all affected test files - Changed assertions from isNotNull() to isInstanceOf(PathResult.Available::class) - Extract path from Available result using: val path = (result as PathResult.Available).path - Changed null assertions to appropriate PathResult variants: - PathResult.OwnershipConflict: when blocks owned by different train or no reservation - PathResult.NoTopologicalPath: when network topology doesn't support path - Updated test comments and descriptions to mention PathResult variants Files updated: - TrainNavigationServiceTest.kt (14 Available, 9 OwnershipConflict, 4 NoTopologicalPath) - TrainPathReservationIntegrationTest.kt (5 Available, 7 OwnershipConflict) - PathDynamicReferencesTest.kt (2 Available) - NavigationModuleKoinTest.kt (2 Available, 1 OwnershipConflict) Total: 23 Available, 17 OwnershipConflict, 4 NoTopologicalPath instances * Implement PathResult sealed class to distinguish permanent vs temporary path unavailability * Fix type mismatch: extract Path from PathResult.Available before passing to extractNavigationBlocks * Fix remaining 3 test assertions: change isNotNull to isInstanceOf(PathResult.Available) --------- Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com>
1 parent 1779417 commit 644255c

10 files changed

Lines changed: 506 additions & 350 deletions

File tree

src/main/kotlin/cz/vutbr/fit/interlockSim/context/navigation/DefaultTrainNavigationService.kt

Lines changed: 38 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
package cz.vutbr.fit.interlockSim.context.navigation
1111

1212
import cz.vutbr.fit.interlockSim.context.SimulationContext
13+
import cz.vutbr.fit.interlockSim.objects.cells.CellUtilities
14+
import cz.vutbr.fit.interlockSim.objects.cells.NodeCell
1315
import cz.vutbr.fit.interlockSim.objects.core.OrientedPathSeparator
1416
import cz.vutbr.fit.interlockSim.objects.core.PathSeparator
1517
import cz.vutbr.fit.interlockSim.objects.paths.ArrayPath
@@ -77,7 +79,7 @@ class DefaultTrainNavigationService(
7779
override fun findReservedPathForTrain(
7880
trainId: String,
7981
separator: PathSeparator
80-
): Path? {
82+
): PathResult {
8183
logger.info {
8284
"findReservedPathForTrain: train '$trainId' requesting path from $separator"
8385
}
@@ -88,21 +90,25 @@ class DefaultTrainNavigationService(
8890
logger.info {
8991
"findReservedPathForTrain: no PathInfo registered for train '$trainId'"
9092
}
91-
return null
93+
return if (hasTopologicalContinuation(separator)) {
94+
PathResult.OwnershipConflict
95+
} else {
96+
PathResult.NoTopologicalPath
97+
}
9298
}
9399

94100
// Step 2: Determine next track section from PathInfo
95101
val dynamicSeparator = context.toDynamic(separator)
96102
val nextTrackSection = determineNextFromPathInfo(dynamicSeparator, pathInfo)
97103

98-
// If separator not in PathInfo, return null (train should wait for proper reservation)
104+
// If separator not in PathInfo, return OwnershipConflict (train should wait for proper reservation)
99105
// Issue #296 Phase 8: Removed fallback mechanism - it returned wrong-direction blocks
100106
if (nextTrackSection == null) {
101107
logger.info {
102108
"findReservedPathForTrain: separator $separator not in PathInfo, " +
103-
"returning null (train should wait for new path reservation)"
109+
"returning OwnershipConflict (train should wait for new path reservation)"
104110
}
105-
return null
111+
return PathResult.OwnershipConflict
106112
}
107113

108114
logger.trace {
@@ -115,7 +121,8 @@ class DefaultTrainNavigationService(
115121
logger.debug {
116122
"findReservedPathForTrain: no path found from $separator with direction $nextTrackSection"
117123
}
118-
return null
124+
// No topological path = permanent condition
125+
return PathResult.NoTopologicalPath
119126
}
120127

121128
// Step 3.5: Handle path transitions (Issue #296 Phase 9)
@@ -152,7 +159,8 @@ class DefaultTrainNavigationService(
152159
"findReservedPathForTrain: block $block is not reserved for train '$trainId' " +
153160
"(owner: ${owner ?: "none"}), path not available"
154161
}
155-
return null // Block not owned by this train, return null (train waits)
162+
// Block not owned by this train, return OwnershipConflict (train waits)
163+
return PathResult.OwnershipConflict
156164
}
157165
}
158166

@@ -161,7 +169,7 @@ class DefaultTrainNavigationService(
161169
"findReservedPathForTrain: train '$trainId' owns all ${blocks.size} blocks in path, " +
162170
"path length ${finalPath.length()}"
163171
}
164-
return finalPath
172+
return PathResult.Available(finalPath)
165173
}
166174

167175
override fun isPathReservedForTrain(
@@ -172,44 +180,16 @@ class DefaultTrainNavigationService(
172180
"isPathReservedForTrain: checking availability for train '$trainId' from $separator"
173181
}
174182

175-
// Step 1: Get PathInfo for this train (Issue #295/#296 Phase 5)
176-
val pathInfo = registry.getPathInfo(trainId)
177-
if (pathInfo == null) {
178-
logger.trace { "isPathReservedForTrain: no PathInfo registered for train '$trainId'" }
179-
return false
180-
}
181-
182-
// Step 2: Determine next track section from PathInfo
183-
val dynamicSeparator = context.toDynamic(separator)
184-
val nextTrackSection = determineNextFromPathInfo(dynamicSeparator, pathInfo)
185-
if (nextTrackSection == null) {
186-
logger.trace { "isPathReservedForTrain: cannot determine next track section from PathInfo" }
187-
return false
188-
}
189-
190-
// Step 3: Build path using known direction
191-
val candidatePath = buildPathWithDirection(dynamicSeparator, nextTrackSection, pathInfo)
192-
if (candidatePath == null) {
193-
logger.trace { "isPathReservedForTrain: no path found with direction" }
194-
return false
195-
}
196-
197-
// Step 4: Extract blocks (reuse existing method)
198-
val blocks = extractDynamicTrackBlocks(candidatePath)
183+
// Delegate to findReservedPathForTrain and check result type
184+
val result = findReservedPathForTrain(trainId, separator)
185+
val isAvailable = result is PathResult.Available
199186

200-
// Step 5: Check ownership (early exit on first conflict)
201-
for (block in blocks) {
202-
val owner = registry.getOwner(block)
203-
if (owner != trainId) {
204-
logger.trace {
205-
"isPathReservedForTrain: block $block not owned by train '$trainId' (owner: ${owner ?: "none"})"
206-
}
207-
return false // Early exit on first conflict
208-
}
187+
logger.trace {
188+
val string = if (isAvailable) "IS" else "IS NOT"
189+
"isPathReservedForTrain: path $string available for train '$trainId' (result: ${result::class.simpleName})"
209190
}
210191

211-
logger.trace { "isPathReservedForTrain: path IS available for train '$trainId'" }
212-
return true
192+
return isAvailable
213193
}
214194

215195
/**
@@ -519,4 +499,19 @@ class DefaultTrainNavigationService(
519499
}
520500
return seen.toList()
521501
}
502+
503+
/**
504+
* Check if there is a topological continuation for the given separator.
505+
*
506+
* This method uses the TopologyNavigator to determine if there is a valid
507+
* next track block in the topology, starting from the given separator.
508+
*
509+
* @param separator The separator to check for topological continuation
510+
* @return True if there is a topological continuation, false otherwise
511+
*/
512+
private fun hasTopologicalContinuation(separator: PathSeparator): Boolean {
513+
val navigator = context.getTopologyNavigator()
514+
val nodeCell = (separator as? NodeCell) ?: CellUtilities.assertNodeCell(separator)
515+
return navigator.getNextTrackBlock(nodeCell, null) != null
516+
}
522517
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/* Brno University of Technology
2+
* Faculty of Information Technology
3+
*
4+
* BSc Thesis 2006/2007
5+
*
6+
* Railway Interlocking Simulator
7+
*
8+
* Bedrich Hovorka
9+
*/
10+
package cz.vutbr.fit.interlockSim.context.navigation
11+
12+
import cz.vutbr.fit.interlockSim.objects.paths.Path
13+
14+
/**
15+
* Result type for train navigation path requests.
16+
*
17+
* ## Purpose
18+
*
19+
* Distinguishes between different reasons why a path might not be available,
20+
* enabling better error handling and diagnostics.
21+
*
22+
* ## Variants
23+
*
24+
* - **Available**: Path exists and all blocks are reserved for the requesting train
25+
* - **NoTopologicalPath**: No path exists in the network topology (permanent condition)
26+
* - **OwnershipConflict**: Path exists but blocks are reserved for a different train (temporary condition)
27+
*
28+
* ## Semantics
29+
*
30+
* ### Permanent vs Temporary Conditions
31+
*
32+
* - **NoTopologicalPath** represents a permanent condition:
33+
* - Network topology doesn't allow a path from current position to target
34+
* - No amount of waiting will resolve this condition
35+
* - Train should either stop (dead-end) or take alternative route
36+
*
37+
* - **OwnershipConflict** represents a temporary condition:
38+
* - Path exists topologically but blocks are currently reserved for different train
39+
* - Condition may resolve when other train releases blocks
40+
* - Train should wait and retry periodically
41+
*
42+
* ## Usage Example
43+
*
44+
* ```kotlin
45+
* val result = trainNavService.findReservedPathForTrain(trainId, separator)
46+
* when (result) {
47+
* is PathResult.Available -> {
48+
* // Path is ready, continue moving
49+
* accelerateToSignal(semaphore, result.path)
50+
* }
51+
* is PathResult.NoTopologicalPath -> {
52+
* // Permanent condition - no path exists
53+
* logger.error("Train $trainId: No topological path from $separator")
54+
* stopAndReportError()
55+
* }
56+
* is PathResult.OwnershipConflict -> {
57+
* // Temporary condition - wait for blocks to become available
58+
* logger.debug("Train $trainId: Path blocked, waiting for dispatcher")
59+
* fireStop()
60+
* hold(5.0) // Wait and retry
61+
* }
62+
* }
63+
* ```
64+
*
65+
* ## Migration from Nullable Path
66+
*
67+
* Previous API returned nullable `Path?`:
68+
* - `null` → now either `NoTopologicalPath` or `OwnershipConflict`
69+
* - `Path` → now `Available(path)`
70+
*
71+
* This sealed class provides better type safety and clearer semantics than
72+
* overloading `null` to mean multiple different conditions.
73+
*
74+
* @see TrainNavigationService.findReservedPathForTrain
75+
* @since Issue #311 (Null Path Semantics Clarification)
76+
*/
77+
sealed class PathResult {
78+
/**
79+
* Path is available and all blocks are reserved for the requesting train.
80+
*
81+
* @param path Complete path from current position to next semaphore
82+
*/
83+
data class Available(val path: Path) : PathResult()
84+
85+
/**
86+
* No topological path exists from current position to target.
87+
*
88+
* This is a **permanent** condition - the network topology doesn't allow
89+
* a path between the current position and the target. No amount of waiting
90+
* will resolve this condition.
91+
*
92+
* Possible causes:
93+
* - Dead-end track with no continuation
94+
* - Disconnected network segments
95+
* - Single InOut with no outbound connections
96+
* - Malformed network topology
97+
*
98+
* Train behavior: Should stop permanently or take alternative route.
99+
*/
100+
data object NoTopologicalPath : PathResult()
101+
102+
/**
103+
* Path exists topologically but blocks are reserved for a different train.
104+
*
105+
* This is a **temporary** condition - blocks may become available when
106+
* the other train releases them.
107+
*
108+
* Possible causes:
109+
* - Blocks reserved for different train
110+
* - No PathInfo registered for this train (dispatcher hasn't reserved path yet)
111+
* - Partial ownership (train owns some but not all blocks in path)
112+
*
113+
* Train behavior: Should wait and retry periodically.
114+
*/
115+
data object OwnershipConflict : PathResult()
116+
}

src/main/kotlin/cz/vutbr/fit/interlockSim/context/navigation/TrainNavigationService.kt

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
package cz.vutbr.fit.interlockSim.context.navigation
1111

1212
import cz.vutbr.fit.interlockSim.objects.core.PathSeparator
13-
import cz.vutbr.fit.interlockSim.objects.paths.Path
1413

1514
/**
1615
* Service for train-specific path navigation within reserved blocks.
@@ -82,45 +81,52 @@ interface TrainNavigationService {
8281
* 1. Build path from separator through next section to next semaphore (existing pathToNextSemaphore logic)
8382
* 2. Extract all track blocks from path
8483
* 3. For each block, validate it is RESERVED for trainId (via PathReservationRegistry)
85-
* 4. If ANY block is not owned by this train, return null (train waits)
86-
* 5. If all blocks are owned, return complete path (train continues)
84+
* 4. If no topological path exists, return NoTopologicalPath (permanent condition)
85+
* 5. If ANY block is not owned by this train, return OwnershipConflict (temporary condition)
86+
* 6. If all blocks are owned, return Available with complete path (train continues)
87+
*
88+
* ## Result Semantics
89+
*
90+
* - **Available(path)**: Path exists and all blocks are reserved for this train
91+
* - **NoTopologicalPath**: Network topology doesn't allow a path (permanent, train should stop)
92+
* - **OwnershipConflict**: Blocks are reserved for different train (temporary, train should wait)
8793
*
8894
* ## Ownership Validation
8995
*
9096
* ```kotlin
9197
* for (block in path.blocks) {
9298
* val owner = registry.getOwner(block)
9399
* if (owner != trainId) {
94-
* return null // Block reserved for different train or not reserved
100+
* return PathResult.OwnershipConflict // Block reserved for different train
95101
* }
96102
* }
97-
* return path // All blocks owned by this train
103+
* return PathResult.Available(path) // All blocks owned by this train
98104
* ```
99105
*
100-
* ## Waiting Behavior
101-
*
102-
* When this method returns null:
103-
* - Train should halt (fireStop)
104-
* - Train waits for path to become available (waitUntil with condition)
105-
* - Train retries periodically (via semaphore signal or timer)
106-
*
107106
* ## Example Usage
108107
*
109108
* ```kotlin
110109
* // In Train.Front.semaphoreAction():
111-
* val path = env.getTrainNavigationService().findReservedPathForTrain(
110+
* val result = env.getTrainNavigationService().findReservedPathForTrain(
112111
* trainId = toString(), // "Train #1"
113-
* separator = semaphore,
114-
* next = next
112+
* separator = semaphore
115113
* )
116114
*
117-
* if (path == null) {
118-
* // Path not reserved for this train, halt and wait
119-
* fireStop()
120-
* waitUntil { pathBecomesAvailable(separator, next) }
121-
* } else {
122-
* // Path is reserved for us, continue
123-
* accelerateToSignal(semaphore, path)
115+
* when (result) {
116+
* is PathResult.Available -> {
117+
* // Path is reserved for us, continue
118+
* accelerateToSignal(semaphore, result.path)
119+
* }
120+
* is PathResult.NoTopologicalPath -> {
121+
* // No path exists (dead-end or disconnected network)
122+
* logger.error("No topological path from $separator")
123+
* stopAndReportError()
124+
* }
125+
* is PathResult.OwnershipConflict -> {
126+
* // Blocks reserved for different train, wait
127+
* fireStop()
128+
* hold(5.0) // Wait and retry
129+
* }
124130
* }
125131
* ```
126132
*
@@ -133,19 +139,20 @@ interface TrainNavigationService {
133139
* val path = env.pathToNextSemaphore(separator, next)
134140
*
135141
* // New approach (with ownership validation):
136-
* val path = trainNavService.findReservedPathForTrain(trainId, separator, next)
142+
* val result = trainNavService.findReservedPathForTrain(trainId, separator)
137143
* ```
138144
*
139-
* The core path-finding logic remains unchanged; this method adds train-specific filtering.
145+
* The core path-finding logic remains unchanged; this method adds train-specific filtering
146+
* and better error reporting.
140147
*
141148
* @param trainId Unique identifier for the train (typically Train.toString())
142149
* @param separator Starting point (semaphore, InOut)
143-
* @return Path to next semaphore if all blocks are reserved for this train, null otherwise
150+
* @return PathResult indicating success (Available) or reason for failure
144151
*/
145152
fun findReservedPathForTrain(
146153
trainId: String,
147154
separator: PathSeparator
148-
): Path?
155+
): PathResult
149156

150157
/**
151158
* Check if a path to the next semaphore is currently reserved for the specified train.

0 commit comments

Comments
 (0)