Skip to content

Commit de72648

Browse files
CopilotbedaHovorka
andauthored
Reduce minimum InOut requirement from 2 to 1 after bidirectional train support (#359)
* Decrease minimum InOut requirement from 2 to 1 (Issue #341, PR #356) * fix detekt * Fix MIN_INOUT_ELEMENTS constant and extract to shared location --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Bedřich Hovorka <bedrich.hovorka@gmail.com>
1 parent e20b62e commit de72648

15 files changed

Lines changed: 118 additions & 134 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,15 +176,15 @@ For complete navigation services architecture, Koin DI integration patterns, and
176176

177177
### InOut Elements
178178

179-
**Minimum Requirement:** Every railway network must have at least 2 InOut elements (entry/exit points).
179+
**Minimum Requirement:** Every railway network must have at least 1 InOut element (entry/exit point).
180180

181181
**Rationale:**
182-
- Single InOut = dead-end (train enters but cannot exit)
183-
- Simulation requires trains to enter from one point and exit from another
182+
- With bidirectional train operation (PR #356), a single InOut can serve as both entry and exit
183+
- Train can enter, travel through the network, reverse direction, and exit through the same InOut
184184
- XML validation enforces this constraint via XMLContextFactory
185185

186186
**Validation:**
187-
- Editor: GUI prevents saving contexts with < 2 InOuts (Issue #80)
187+
- Editor: GUI prevents saving contexts with < 1 InOuts (Issue #80)
188188
- XML loading: XMLContextFactory validates during parse
189189
- Test coverage: See InOutValidationTest (Issue #79)
190190

docs/KOTLIN_STYLE_GUIDE.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2058,9 +2058,10 @@ dynamicInOut.lastTrain = train // Mutable operation (OK in simulation)
20582058

20592059
**Railway Domain Context:**
20602060
- **InOut elements** represent network boundaries (train spawn/despawn points)
2061-
- **Minimum 2 InOuts required** per network (validated by XMLContextFactory)
2061+
- **Minimum 1 InOut required** per network (validated by XMLContextFactory)
20622062
- **Entry points** (`isEntry == true`) - trains enter network here
20632063
- **Exit points** (`isEntry == false`) - trains leave network here
2064+
- With bidirectional operation, a single InOut can serve as both entry and exit
20642065

20652066
**Why This Pattern:**
20662067
1. **Immutable editing context** - No simulation state during network design
@@ -2217,24 +2218,23 @@ class DefaultSimulationContext(
22172218

22182219
### Minimum InOut Requirement
22192220

2220-
**Requirement:** Every railway network must have at least 2 InOut elements (entry/exit points).
2221+
**Requirement:** Every railway network must have at least 1 InOut element (entry/exit point).
22212222

22222223
**Rationale:**
2223-
- Single InOut = dead-end (train enters but cannot exit)
2224-
- Simulation requires trains to enter from one point and exit from another
2225-
- Networks with < 2 InOuts are topologically invalid for train simulation
2224+
- With bidirectional train operation (PR #356), a single InOut can serve as both entry and exit
2225+
- Train can enter, travel through the network, reverse direction, and exit through the same InOut
2226+
- Networks with < 1 InOuts are invalid (no way for trains to enter/exit)
22262227

22272228
**Validation:**
2228-
- **Editor**: GUI prevents saving contexts with < 2 InOuts (Issue #80)
2229+
- **Editor**: GUI prevents saving contexts with < 1 InOuts (Issue #80)
22292230
- **XML loading**: XMLContextFactory validates during parse and throws `IllegalArgumentException`
22302231
- **Test coverage**: See `InOutValidationTest` (Issue #79)
22312232

2232-
**Example Valid Network:**
2233+
**Example Valid Network (single InOut):**
22332234
```xml
22342235
<RailwayNet>
2235-
<InOut name="Entry" x="1" y="1" entry="true" />
2236-
<InOut name="Exit" x="10" y="10" entry="false" />
2237-
<!-- At least 2 InOuts required -->
2236+
<InOut name="EntryExit" x="1" y="1" entry="true" />
2237+
<!-- At least 1 InOut required; with bidirectional trains, this can serve as both entry and exit -->
22382238
</RailwayNet>
22392239
```
22402240

docs/STATIC_DYNAMIC_SEPARATION_ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ dynamicInOut.lastTrain = train // Mutable operation (simulation state)
321321

322322
**Railway Domain Context:**
323323
- **InOut elements** represent entry/exit points for trains (network boundaries)
324-
- **Minimum 2 InOuts required** per network (at least one entry, one exit)
324+
- **Minimum 1 InOut required** per network (with bidirectional operation, can serve as both entry and exit)
325325
- **Entry points** (`isEntry == true`) - trains spawn here
326326
- **Exit points** (`isEntry == false`) - trains despawn here
327327
- **Examples:** "A" (entry), "B" (exit) in vyhybna.xml; "N-Lib-1" (north entry), "S-Vrs-2" (south exit) in praha-hlavni-nadrazi.xml

src/main/kotlin/cz/vutbr/fit/interlockSim/gui/ValidationUtils.kt

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

1212
import cz.vutbr.fit.interlockSim.context.ContextCreationException
13+
import cz.vutbr.fit.interlockSim.xml.XMLContextFactory.Companion.MIN_INOUT_ELEMENTS
1314
import org.xml.sax.SAXException
1415
import org.xml.sax.SAXParseException
1516
import java.io.FileNotFoundException
@@ -109,15 +110,14 @@ object ValidationUtils {
109110
*/
110111
private fun parseErrorMessage(message: String): String =
111112
when {
112-
message.contains("InOut") && message.contains("at least 2") -> {
113+
message.contains("InOut") && message.contains("at least $MIN_INOUT_ELEMENTS") -> {
113114
"""
114-
|Minimum 2 InOut elements required (found fewer).
115+
|Minimum $MIN_INOUT_ELEMENTS InOut element required (found none).
115116
|
116-
|InOut elements define entry/exit points for trains. At least 2 are required for simulation:
117-
|- One for trains to enter the network
118-
|- One for trains to exit the network
117+
|InOut elements define entry/exit points for trains. At least $MIN_INOUT_ELEMENTS is required for simulation.
118+
|With bidirectional train operation, a single InOut can serve as both entry and exit point.
119119
|
120-
|Please add more InOut elements to your railway network.
120+
|Please add an InOut element to your railway network.
121121
""".trimMargin()
122122
}
123123

src/main/kotlin/cz/vutbr/fit/interlockSim/xml/XMLContextFactory.kt

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,9 @@ class XMLContextFactory : EditingContextFactory {
293293
* Called when XML parsing completes. Validates the parsed railway network structure.
294294
*
295295
* **Validation Rules:**
296-
* - Minimum [MIN_INOUT_ELEMENTS] InOut elements required (entry and exit points)
296+
* - Minimum [MIN_INOUT_ELEMENTS] InOut element required (entry/exit point)
297297
* - InOut elements define where trains enter/exit the railway network
298-
* - Single InOut networks are invalid (dead-end, train cannot exit)
298+
* - With bidirectional operation, a single InOut can serve as both entry and exit
299299
* - Context must be initialized (non-null editingContext)
300300
*
301301
* This method is called automatically by the SAX parser after all XML elements
@@ -305,15 +305,14 @@ class XMLContextFactory : EditingContextFactory {
305305
* ```xml
306306
* <net X="10" Y="10">
307307
* <InOut name="ENTRY" ... />
308-
* <InOut name="EXIT" ... />
309-
* <!-- other elements -->
308+
* <!-- Optional: Additional InOut for dedicated exit -->
310309
* </net>
311310
* ```
312311
*
313312
* **Example Invalid Network (throws exception):**
314313
* ```xml
315314
* <net X="10" Y="10">
316-
* <InOut name="ENTRY" ... /> <!-- Only 1 InOut = invalid -->
315+
* <!-- No InOut = invalid -->
317316
* </net>
318317
* ```
319318
*
@@ -325,9 +324,9 @@ class XMLContextFactory : EditingContextFactory {
325324
* @see MIN_INOUT_ELEMENTS for validation threshold
326325
*/
327326
override fun endDocument() {
328-
// Strict validation: Railway networks must have at least 2 InOut elements (entry/exit points)
327+
// Strict validation: Railway networks must have at least 1 InOut element (entry/exit point)
329328
val ctx = editingContext ?: throw SAXException("Context not initialized")
330-
329+
331330
// Only validate InOut count if not skipping structural validation
332331
if (!skipStructuralValidation) {
333332
// Access inouts via public method from BaseContext
@@ -430,13 +429,14 @@ class XMLContextFactory : EditingContextFactory {
430429
/**
431430
* Minimum number of InOut elements required in a railway network.
432431
*
433-
* Railway networks must have at least 2 InOut elements (entry and exit points)
434-
* to allow trains to enter and exit the simulation. Single InOut networks
435-
* are invalid (dead-end configuration).
432+
* Railway networks must have at least 1 InOut element (entry/exit point).
433+
* With bidirectional train operation (Issue #356), a single InOut can serve
434+
* as both entry and exit point.
436435
*
437436
* @since 2026-01 (Issue #76 validation, Issue #77 code quality)
437+
* @since 2026-02 (Issue #341, PR #356 - reduced from 2 to 1 for bidirectional support)
438438
*/
439-
private const val MIN_INOUT_ELEMENTS = 2
439+
const val MIN_INOUT_ELEMENTS = 1
440440
}
441441

442442
override fun createEmptyContext(): EditingContext = DefaultEditingContext(DEFAULT_GRID_SIZE, DEFAULT_GRID_SIZE)
@@ -445,16 +445,16 @@ class XMLContextFactory : EditingContextFactory {
445445
* Creates an EditingContext by parsing an XML file conforming to data.xsd schema.
446446
*
447447
* **Validation Requirements:**
448-
* - Minimum 2 InOut elements required (entry and exit points)
448+
* - Minimum 1 InOut element required (entry/exit point)
449449
* - InOut elements define where trains enter/exit the railway network
450-
* - Single InOut networks are invalid (dead-end, train cannot exit)
450+
* - With bidirectional operation, a single InOut can serve as both entry and exit
451451
*
452452
* @param file XML file containing railway network definition
453453
* @return Parsed EditingContext with validated network structure
454454
* @throws ContextCreationException if:
455455
* - File not found
456456
* - XML validation fails against schema
457-
* - InOut count < 2 (minimum requirement)
457+
* - InOut count < 1 (minimum requirement)
458458
* - Network structure is invalid
459459
*/
460460
@Throws(ContextCreationException::class)
@@ -493,15 +493,15 @@ class XMLContextFactory : EditingContextFactory {
493493
* Creates an EditingContext by parsing an XML stream conforming to data.xsd schema.
494494
*
495495
* **Validation Requirements:**
496-
* - Minimum 2 InOut elements required (entry and exit points)
496+
* - Minimum 1 InOut element required (entry/exit point)
497497
* - InOut elements define where trains enter/exit the railway network
498-
* - Single InOut networks are invalid (dead-end, train cannot exit)
498+
* - With bidirectional operation, a single InOut can serve as both entry and exit
499499
*
500500
* @param stream InputStream containing XML railway network definition
501501
* @return Parsed EditingContext with validated network structure
502502
* @throws ContextCreationException if:
503503
* - XML validation fails against schema
504-
* - InOut count < 2 (minimum requirement)
504+
* - InOut count < 1 (minimum requirement)
505505
* - Network structure is invalid
506506
*/
507507
@Throws(ContextCreationException::class)
@@ -573,10 +573,10 @@ class XMLContextFactory : EditingContextFactory {
573573
try {
574574
val inputSource = InputSource(java.io.StringReader(xmlContent))
575575
val handler = Handler(skipStructuralValidation = false)
576-
576+
577577
// Try to validate with schema - this will throw on both parse and validation errors
578578
validator.validate(SAXSource(inputSource), SAXResult(handler))
579-
579+
580580
val context = handler.getContext()
581581
if (context == null) {
582582
return LenientParseResult(
@@ -604,22 +604,22 @@ class XMLContextFactory : EditingContextFactory {
604604
// SAXException (not SAXParseException) = validation error
605605
// Could be schema validation or structural validation (e.g., InOut count < 2)
606606
// Try parsing without validation to see if we can get a context
607-
607+
608608
val validationError = ContextCreationException(e)
609-
609+
610610
// Second attempt: Parse without schema validation but WITH structural validation disabled
611611
return try {
612612
val inputSource = InputSource(java.io.StringReader(xmlContent))
613613
val handler = Handler(skipStructuralValidation = true)
614-
614+
615615
// Parse without schema validation - use SAX parser directly
616616
val saxParserFactory = javax.xml.parsers.SAXParserFactory.newInstance()
617617
saxParserFactory.isNamespaceAware = true // Keep namespace awareness
618618
val saxParser = saxParserFactory.newSAXParser()
619619
saxParser.parse(inputSource, handler)
620-
620+
621621
val context = handler.getContext()
622-
622+
623623
if (context != null) {
624624
// Successfully parsed without validation - it's parseable with errors
625625
LenientParseResult(

src/test/kotlin/cz/vutbr/fit/interlockSim/InvalidNetworkTest.kt

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class InvalidNetworkTest : KoinTestBase() {
5555
inner class MissingElementsTests {
5656
/**
5757
* Test: Network with no InOut elements must be rejected (strict validation)
58-
* Rationale: Railway networks require at least 2 InOut elements (entry and exit points)
58+
* Rationale: Railway networks require at least 1 InOut element (entry/exit point)
5959
*/
6060
@Test
6161
fun createContext_noInOutElements_throwsException() {
@@ -71,21 +71,22 @@ class InvalidNetworkTest : KoinTestBase() {
7171
}
7272

7373
/**
74-
* Test: Network with single InOut must be rejected (strict validation)
75-
* Rationale: Railway networks require at least 2 InOut elements (entry and exit points)
74+
* Test: Network with single InOut is now valid (with bidirectional operation)
75+
* Rationale: With bidirectional train operation, a single InOut can serve as both entry and exit
7676
*/
7777
@Test
78-
fun createContext_singleInOutNoTracks_throwsException() {
78+
fun createContext_singleInOutNoTracks_isValid() {
7979
val networkXML = """<?xml version="1.0"?>
8080
<!DOCTYPE net>
8181
<net X="100" Y="100">
8282
<InOut X="10" Y="10" SpatialType="HORIZONTAL" orientation="false" name="LONE_INOUT"/>
8383
</net>"""
8484
val stream = ByteArrayInputStream(networkXML.toByteArray())
8585

86-
// Strict validation: single InOut is insufficient
87-
assertThatBlock { editingContextFactory.createContext(stream) }
88-
.isFailure()
86+
// With bidirectional operation, single InOut is now valid
87+
val context = editingContextFactory.createContext(stream)
88+
assertThat(context).isNotNull()
89+
context.close()
8990
}
9091

9192
/**

src/test/kotlin/cz/vutbr/fit/interlockSim/context/InOutValidationTest.kt

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import assertk.assertions.isEqualTo
2222
import assertk.assertions.isInstanceOf
2323
import cz.vutbr.fit.interlockSim.testutil.TestContextBuilder
2424
import cz.vutbr.fit.interlockSim.testutil.TestFixtures
25+
import cz.vutbr.fit.interlockSim.xml.XMLContextFactory
2526
import org.junit.jupiter.api.AfterEach
2627
import org.junit.jupiter.api.BeforeEach
2728
import org.junit.jupiter.api.Test
@@ -33,10 +34,10 @@ import org.koin.java.KoinJavaComponent.getKoin
3334
* Test suite for InOut validation rules.
3435
*
3536
* **Requirements:**
36-
* - Minimum 2 InOut elements required (entry and exit points)
37-
* - Single InOut networks are invalid (dead-end)
37+
* - Minimum 1 InOut element required (entry/exit point)
3838
* - Networks with 0 InOuts are invalid
39-
* - Networks with 2+ InOuts are valid
39+
* - Networks with 1+ InOuts are valid
40+
* - With bidirectional operation, a single InOut can serve as both entry and exit
4041
*
4142
* **Coverage:**
4243
* - XMLContextFactory validation during XML parsing
@@ -65,33 +66,33 @@ class InOutValidationTest {
6566
/**
6667
* Test: Context with 0 InOuts throws exception during XML loading.
6768
*
68-
* Expected: ContextCreationException with message about minimum 2 InOuts.
69+
* Expected: ContextCreationException with message about minimum 1 InOut.
6970
*/
7071
@Test
7172
fun `XML with 0 InOuts throws ContextCreationException`() {
73+
val min = XMLContextFactory.Companion.MIN_INOUT_ELEMENTS
7274
assertFailure {
7375
TestFixtures.loadInvalidInOutXml("zero-inouts.xml").use { stream ->
7476
xmlFactory.createContext(stream)
7577
}
7678
}.isInstanceOf(ContextCreationException::class)
7779
.transform { it.message ?: "" }
78-
.contains("at least 2 InOut")
80+
.contains("Railway network must have at least $min InOut elements (entry and exit points).")
7981
}
8082

8183
/**
82-
* Test: Context with 1 InOut throws exception during XML loading.
84+
* Test: Context with 1 InOut passes validation.
8385
*
84-
* Expected: ContextCreationException with message about minimum 2 InOuts.
86+
* With bidirectional train operation, a single InOut is now valid.
87+
* Expected: Context created successfully with 1 InOut.
8588
*/
8689
@Test
87-
fun `XML with 1 InOut throws ContextCreationException`() {
88-
assertFailure {
89-
TestFixtures.loadInvalidInOutXml("single-inout.xml").use { stream ->
90-
xmlFactory.createContext(stream)
90+
fun `XML with 1 InOut passes validation`() {
91+
TestFixtures.loadInvalidInOutXml("single-inout.xml").use { stream ->
92+
xmlFactory.createContext(stream).use { context ->
93+
assertThat(context.asEditingContext().getInOutsList()).hasSize(1)
9194
}
92-
}.isInstanceOf(ContextCreationException::class)
93-
.transform { it.message ?: "" }
94-
.contains("at least 2 InOut")
95+
}
9596
}
9697

9798
/**
@@ -179,17 +180,17 @@ class InOutValidationTest {
179180
/**
180181
* Test: Error message contains helpful explanation.
181182
*
182-
* Expected: Exception message explains why 2 InOuts are required.
183+
* Expected: Exception message explains why 1 InOut is required.
183184
*/
184185
@Test
185186
fun `Error message explains InOut requirement clearly`() {
186187
assertFailure {
187-
TestFixtures.loadInvalidInOutXml("single-inout.xml").use { stream ->
188+
TestFixtures.loadInvalidInOutXml("zero-inouts.xml").use { stream ->
188189
xmlFactory.createContext(stream)
189190
}
190191
}.isInstanceOf(ContextCreationException::class)
191192
.transform { it.message ?: "" }
192-
.contains("entry and exit points")
193+
.contains("entry/exit point")
193194
}
194195

195196
/**

0 commit comments

Comments
 (0)