Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,15 @@ For complete navigation services architecture, Koin DI integration patterns, and

### InOut Elements

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

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

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

Expand Down
20 changes: 10 additions & 10 deletions docs/KOTLIN_STYLE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2058,9 +2058,10 @@ dynamicInOut.lastTrain = train // Mutable operation (OK in simulation)

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

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

### Minimum InOut Requirement

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

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

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

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

Expand Down
2 changes: 1 addition & 1 deletion docs/STATIC_DYNAMIC_SEPARATION_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ dynamicInOut.lastTrain = train // Mutable operation (simulation state)

**Railway Domain Context:**
- **InOut elements** represent entry/exit points for trains (network boundaries)
- **Minimum 2 InOuts required** per network (at least one entry, one exit)
- **Minimum 1 InOut required** per network (with bidirectional operation, can serve as both entry and exit)
- **Entry points** (`isEntry == true`) - trains spawn here
- **Exit points** (`isEntry == false`) - trains despawn here
- **Examples:** "A" (entry), "B" (exit) in vyhybna.xml; "N-Lib-1" (north entry), "S-Vrs-2" (south exit) in praha-hlavni-nadrazi.xml
Expand Down
12 changes: 6 additions & 6 deletions src/main/kotlin/cz/vutbr/fit/interlockSim/gui/ValidationUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
package cz.vutbr.fit.interlockSim.gui

import cz.vutbr.fit.interlockSim.context.ContextCreationException
import cz.vutbr.fit.interlockSim.xml.XMLContextFactory.Companion.MIN_INOUT_ELEMENTS
import org.xml.sax.SAXException
import org.xml.sax.SAXParseException
import java.io.FileNotFoundException
Expand Down Expand Up @@ -109,15 +110,14 @@ object ValidationUtils {
*/
private fun parseErrorMessage(message: String): String =
when {
message.contains("InOut") && message.contains("at least 2") -> {
message.contains("InOut") && message.contains("at least $MIN_INOUT_ELEMENTS") -> {
"""
|Minimum 2 InOut elements required (found fewer).
|Minimum $MIN_INOUT_ELEMENTS InOut element required (found none).
|
|InOut elements define entry/exit points for trains. At least 2 are required for simulation:
|- One for trains to enter the network
|- One for trains to exit the network
|InOut elements define entry/exit points for trains. At least $MIN_INOUT_ELEMENTS is required for simulation.
|With bidirectional train operation, a single InOut can serve as both entry and exit point.
|
|Please add more InOut elements to your railway network.
|Please add an InOut element to your railway network.
""".trimMargin()
}

Expand Down
48 changes: 24 additions & 24 deletions src/main/kotlin/cz/vutbr/fit/interlockSim/xml/XMLContextFactory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,9 @@ class XMLContextFactory : EditingContextFactory {
* Called when XML parsing completes. Validates the parsed railway network structure.
*
* **Validation Rules:**
* - Minimum [MIN_INOUT_ELEMENTS] InOut elements required (entry and exit points)
* - Minimum [MIN_INOUT_ELEMENTS] InOut element required (entry/exit point)
* - InOut elements define where trains enter/exit the railway network
* - Single InOut networks are invalid (dead-end, train cannot exit)
* - With bidirectional operation, a single InOut can serve as both entry and exit
* - Context must be initialized (non-null editingContext)
*
* This method is called automatically by the SAX parser after all XML elements
Expand All @@ -305,15 +305,14 @@ class XMLContextFactory : EditingContextFactory {
* ```xml
* <net X="10" Y="10">
* <InOut name="ENTRY" ... />
* <InOut name="EXIT" ... />
* <!-- other elements -->
* <!-- Optional: Additional InOut for dedicated exit -->
* </net>
* ```
*
* **Example Invalid Network (throws exception):**
* ```xml
* <net X="10" Y="10">
* <InOut name="ENTRY" ... /> <!-- Only 1 InOut = invalid -->
* <!-- No InOut = invalid -->
* </net>
* ```
*
Expand All @@ -325,9 +324,9 @@ class XMLContextFactory : EditingContextFactory {
* @see MIN_INOUT_ELEMENTS for validation threshold
*/
override fun endDocument() {
// Strict validation: Railway networks must have at least 2 InOut elements (entry/exit points)
// Strict validation: Railway networks must have at least 1 InOut element (entry/exit point)
val ctx = editingContext ?: throw SAXException("Context not initialized")

// Only validate InOut count if not skipping structural validation
if (!skipStructuralValidation) {
// Access inouts via public method from BaseContext
Expand Down Expand Up @@ -430,13 +429,14 @@ class XMLContextFactory : EditingContextFactory {
/**
* Minimum number of InOut elements required in a railway network.
*
* Railway networks must have at least 2 InOut elements (entry and exit points)
* to allow trains to enter and exit the simulation. Single InOut networks
* are invalid (dead-end configuration).
* Railway networks must have at least 1 InOut element (entry/exit point).
* With bidirectional train operation (Issue #356), a single InOut can serve
* as both entry and exit point.
*
* @since 2026-01 (Issue #76 validation, Issue #77 code quality)
* @since 2026-02 (Issue #341, PR #356 - reduced from 2 to 1 for bidirectional support)
*/
private const val MIN_INOUT_ELEMENTS = 2
const val MIN_INOUT_ELEMENTS = 1
}

override fun createEmptyContext(): EditingContext = DefaultEditingContext(DEFAULT_GRID_SIZE, DEFAULT_GRID_SIZE)
Expand All @@ -445,16 +445,16 @@ class XMLContextFactory : EditingContextFactory {
* Creates an EditingContext by parsing an XML file conforming to data.xsd schema.
*
* **Validation Requirements:**
* - Minimum 2 InOut elements required (entry and exit points)
* - Minimum 1 InOut element required (entry/exit point)
* - InOut elements define where trains enter/exit the railway network
* - Single InOut networks are invalid (dead-end, train cannot exit)
* - With bidirectional operation, a single InOut can serve as both entry and exit
*
* @param file XML file containing railway network definition
* @return Parsed EditingContext with validated network structure
* @throws ContextCreationException if:
* - File not found
* - XML validation fails against schema
* - InOut count < 2 (minimum requirement)
* - InOut count < 1 (minimum requirement)
* - Network structure is invalid
*/
@Throws(ContextCreationException::class)
Expand Down Expand Up @@ -493,15 +493,15 @@ class XMLContextFactory : EditingContextFactory {
* Creates an EditingContext by parsing an XML stream conforming to data.xsd schema.
*
* **Validation Requirements:**
* - Minimum 2 InOut elements required (entry and exit points)
* - Minimum 1 InOut element required (entry/exit point)
* - InOut elements define where trains enter/exit the railway network
* - Single InOut networks are invalid (dead-end, train cannot exit)
* - With bidirectional operation, a single InOut can serve as both entry and exit
*
* @param stream InputStream containing XML railway network definition
* @return Parsed EditingContext with validated network structure
* @throws ContextCreationException if:
* - XML validation fails against schema
* - InOut count < 2 (minimum requirement)
* - InOut count < 1 (minimum requirement)
* - Network structure is invalid
*/
@Throws(ContextCreationException::class)
Expand Down Expand Up @@ -573,10 +573,10 @@ class XMLContextFactory : EditingContextFactory {
try {
val inputSource = InputSource(java.io.StringReader(xmlContent))
val handler = Handler(skipStructuralValidation = false)

// Try to validate with schema - this will throw on both parse and validation errors
validator.validate(SAXSource(inputSource), SAXResult(handler))

val context = handler.getContext()
if (context == null) {
return LenientParseResult(
Expand Down Expand Up @@ -604,22 +604,22 @@ class XMLContextFactory : EditingContextFactory {
// SAXException (not SAXParseException) = validation error
// Could be schema validation or structural validation (e.g., InOut count < 2)
// Try parsing without validation to see if we can get a context

val validationError = ContextCreationException(e)

// Second attempt: Parse without schema validation but WITH structural validation disabled
return try {
val inputSource = InputSource(java.io.StringReader(xmlContent))
val handler = Handler(skipStructuralValidation = true)

// Parse without schema validation - use SAX parser directly
val saxParserFactory = javax.xml.parsers.SAXParserFactory.newInstance()
saxParserFactory.isNamespaceAware = true // Keep namespace awareness
val saxParser = saxParserFactory.newSAXParser()
saxParser.parse(inputSource, handler)

val context = handler.getContext()

if (context != null) {
// Successfully parsed without validation - it's parseable with errors
LenientParseResult(
Expand Down
15 changes: 8 additions & 7 deletions src/test/kotlin/cz/vutbr/fit/interlockSim/InvalidNetworkTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class InvalidNetworkTest : KoinTestBase() {
inner class MissingElementsTests {
/**
* Test: Network with no InOut elements must be rejected (strict validation)
* Rationale: Railway networks require at least 2 InOut elements (entry and exit points)
* Rationale: Railway networks require at least 1 InOut element (entry/exit point)
*/
@Test
fun createContext_noInOutElements_throwsException() {
Expand All @@ -71,21 +71,22 @@ class InvalidNetworkTest : KoinTestBase() {
}

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

// Strict validation: single InOut is insufficient
assertThatBlock { editingContextFactory.createContext(stream) }
.isFailure()
// With bidirectional operation, single InOut is now valid
val context = editingContextFactory.createContext(stream)
assertThat(context).isNotNull()
context.close()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import cz.vutbr.fit.interlockSim.testutil.TestContextBuilder
import cz.vutbr.fit.interlockSim.testutil.TestFixtures
import cz.vutbr.fit.interlockSim.xml.XMLContextFactory
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
Expand All @@ -33,10 +34,10 @@ import org.koin.java.KoinJavaComponent.getKoin
* Test suite for InOut validation rules.
*
* **Requirements:**
* - Minimum 2 InOut elements required (entry and exit points)
* - Single InOut networks are invalid (dead-end)
* - Minimum 1 InOut element required (entry/exit point)
* - Networks with 0 InOuts are invalid
* - Networks with 2+ InOuts are valid
* - Networks with 1+ InOuts are valid
* - With bidirectional operation, a single InOut can serve as both entry and exit
*
* **Coverage:**
* - XMLContextFactory validation during XML parsing
Expand Down Expand Up @@ -65,33 +66,33 @@ class InOutValidationTest {
/**
* Test: Context with 0 InOuts throws exception during XML loading.
*
* Expected: ContextCreationException with message about minimum 2 InOuts.
* Expected: ContextCreationException with message about minimum 1 InOut.
*/
@Test
fun `XML with 0 InOuts throws ContextCreationException`() {
val min = XMLContextFactory.Companion.MIN_INOUT_ELEMENTS
assertFailure {
TestFixtures.loadInvalidInOutXml("zero-inouts.xml").use { stream ->
xmlFactory.createContext(stream)
}
}.isInstanceOf(ContextCreationException::class)
.transform { it.message ?: "" }
.contains("at least 2 InOut")
.contains("Railway network must have at least $min InOut elements (entry and exit points).")
}

/**
* Test: Context with 1 InOut throws exception during XML loading.
* Test: Context with 1 InOut passes validation.
*
* Expected: ContextCreationException with message about minimum 2 InOuts.
* With bidirectional train operation, a single InOut is now valid.
* Expected: Context created successfully with 1 InOut.
*/
@Test
fun `XML with 1 InOut throws ContextCreationException`() {
assertFailure {
TestFixtures.loadInvalidInOutXml("single-inout.xml").use { stream ->
xmlFactory.createContext(stream)
fun `XML with 1 InOut passes validation`() {
TestFixtures.loadInvalidInOutXml("single-inout.xml").use { stream ->
xmlFactory.createContext(stream).use { context ->
assertThat(context.asEditingContext().getInOutsList()).hasSize(1)
}
}.isInstanceOf(ContextCreationException::class)
.transform { it.message ?: "" }
.contains("at least 2 InOut")
}
}

/**
Expand Down Expand Up @@ -179,17 +180,17 @@ class InOutValidationTest {
/**
* Test: Error message contains helpful explanation.
*
* Expected: Exception message explains why 2 InOuts are required.
* Expected: Exception message explains why 1 InOut is required.
*/
@Test
fun `Error message explains InOut requirement clearly`() {
assertFailure {
TestFixtures.loadInvalidInOutXml("single-inout.xml").use { stream ->
TestFixtures.loadInvalidInOutXml("zero-inouts.xml").use { stream ->
xmlFactory.createContext(stream)
}
}.isInstanceOf(ContextCreationException::class)
.transform { it.message ?: "" }
.contains("entry and exit points")
.contains("entry/exit point")
}

/**
Expand Down
Loading