Skip to content

Commit 7359aee

Browse files
feat: Add strict validation for XMLContextFactory - require minimum 2… (#76)
* feat: Add strict validation for XMLContextFactory - require minimum 2 InOut elements Implements fail-fast validation strategy for railway network configurations: - XMLContextFactory now rejects networks with fewer than 2 InOut elements - Railway networks must have at least one entry and one exit point - Nested Net elements already rejected (existing validation) Changes: - XMLContextFactory.kt: Added InOut count validation in endDocument() - InvalidNetworkTest.kt: Updated tests to expect failures for invalid networks - Test fixtures: Updated minimal-network.xml and empty-grid.xml to include 2 InOut elements - Updated test expectations in XMLContextFactoryTest, ContextInitializationTest, ShuntingLoopTest Resolves #29 Co-authored-by: bedaHovorka <bedaHovorka@users.noreply.github.com> * fix --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
1 parent ace99bd commit 7359aee

8 files changed

Lines changed: 68 additions & 41 deletions

File tree

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,15 @@ class XMLContextFactory :
254254
}
255255

256256
override fun endDocument() {
257-
// Note: InOut validation removed - contexts can be created without InOut elements
258-
// for editing purposes. Simulation validation happens when run() is called.
257+
// Strict validation: Railway networks must have at least 2 InOut elements (entry/exit points)
258+
val ctx = context ?: throw SAXException("Context not initialized")
259+
val inOuts = ctx.getInOuts()
260+
if (inOuts.size < 2) {
261+
throw SAXException(
262+
"Railway network must have at least 2 InOut elements (entry and exit points). " +
263+
"Found: ${inOuts.size}"
264+
)
265+
}
259266
ended = true
260267
}
261268

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

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import assertk.assertThat
1313
import assertk.assertions.isFailure
1414
import assertk.assertions.isInstanceOf
1515
import assertk.assertions.isNotNull
16-
import assertk.assertions.isTrue
1716
import cz.vutbr.fit.interlockSim.testutil.KoinTestBase
1817
import cz.vutbr.fit.interlockSim.xml.XMLContextFactory
1918
import org.junit.jupiter.api.*
@@ -53,40 +52,38 @@ class InvalidNetworkTest : KoinTestBase() {
5352
@DisplayName("Missing Required Elements")
5453
inner class MissingElementsTests {
5554
/**
56-
* Test: Network with no InOut elements can be created (for editing)
57-
* but will fail when trying to run simulation (InOut required for train operations)
58-
* Rationale: InOut (entry/exit points) are required for simulation, not for context creation
55+
* Test: Network with no InOut elements must be rejected (strict validation)
56+
* Rationale: Railway networks require at least 2 InOut elements (entry and exit points)
5957
*/
6058
@Test
61-
fun createContext_noInOutElements_succeeds() {
59+
fun createContext_noInOutElements_throwsException() {
6260
val networkXML = """<?xml version="1.0"?>
6361
<!DOCTYPE net>
6462
<net X="100" Y="100">
6563
</net>"""
6664
val stream = ByteArrayInputStream(networkXML.toByteArray())
6765

68-
// Context creation should succeed (allows editing empty networks)
69-
val context = factory.createContext(stream)
70-
assertThat(context).isNotNull()
71-
assertThat(context.getInOuts().isEmpty()).isTrue()
66+
// Strict validation: must reject networks without sufficient InOut elements
67+
assertThatBlock { factory.createContext(stream) }
68+
.isFailure()
7269
}
7370

7471
/**
75-
* Test: Network with single InOut but no tracks for connectivity
76-
* Rationale: Single isolated InOut is valid but useless for simulation
72+
* Test: Network with single InOut must be rejected (strict validation)
73+
* Rationale: Railway networks require at least 2 InOut elements (entry and exit points)
7774
*/
7875
@Test
79-
fun createContext_singleInOutNoTracks_succeeds() {
76+
fun createContext_singleInOutNoTracks_throwsException() {
8077
val networkXML = """<?xml version="1.0"?>
8178
<!DOCTYPE net>
8279
<net X="100" Y="100">
8380
<InOut X="10" Y="10" SpatialType="HORIZONTAL" orientation="false" name="LONE_INOUT"/>
8481
</net>"""
8582
val stream = ByteArrayInputStream(networkXML.toByteArray())
8683

87-
// Single InOut is valid - creation should succeed
88-
val context = factory.createContext(stream)
89-
assertThat(context).isNotNull()
84+
// Strict validation: single InOut is insufficient
85+
assertThatBlock { factory.createContext(stream) }
86+
.isFailure()
9087
}
9188

9289
/**
@@ -647,7 +644,8 @@ class InvalidNetworkTest : KoinTestBase() {
647644
val largeGridXML = """<?xml version="1.0"?>
648645
<!DOCTYPE net>
649646
<net X="9999" Y="9999">
650-
<InOut X="100" Y="100" SpatialType="HORIZONTAL" orientation="false" name="A"/>
647+
<InOut X="100" Y="100" SpatialType="HORIZONTAL" orientation="true" name="ENTRY"/>
648+
<InOut X="200" Y="100" SpatialType="HORIZONTAL" orientation="false" name="EXIT"/>
651649
</net>"""
652650
val stream = ByteArrayInputStream(largeGridXML.toByteArray())
653651

@@ -663,8 +661,9 @@ class InvalidNetworkTest : KoinTestBase() {
663661
fun createContext_minimumGridSize_succeeds() {
664662
val minGridXML = """<?xml version="1.0"?>
665663
<!DOCTYPE net>
666-
<net X="1" Y="1">
667-
<InOut X="0" Y="0" SpatialType="HORIZONTAL" orientation="false" name="MINIMAL"/>
664+
<net X="10" Y="10">
665+
<InOut X="0" Y="0" SpatialType="HORIZONTAL" orientation="true" name="ENTRY"/>
666+
<InOut X="1" Y="0" SpatialType="HORIZONTAL" orientation="false" name="EXIT"/>
668667
</net>"""
669668
val stream = ByteArrayInputStream(minGridXML.toByteArray())
670669

@@ -681,6 +680,7 @@ class InvalidNetworkTest : KoinTestBase() {
681680
val boundaryXML = """<?xml version="1.0"?>
682681
<!DOCTYPE net>
683682
<net X="100" Y="100">
683+
<InOut X="0" Y="0" SpatialType="HORIZONTAL" orientation="true" name="ENTRY"/>
684684
<InOut X="99" Y="99" SpatialType="HORIZONTAL" orientation="false" name="CORNER"/>
685685
</net>"""
686686
val stream = ByteArrayInputStream(boundaryXML.toByteArray())

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ class ContextInitializationTest : KoinTestBase() {
141141
/**
142142
* Validates that loading a valid XML file creates context with expected content.
143143
*
144-
* Test uses minimal-network.xml fixture: simple 2-cell network with one InOut.
144+
* Test uses minimal-network.xml fixture: simple network with two InOut nodes (minimum required).
145145
*
146146
* Railway context: XML files are the persistent format for railway network configs.
147147
*/

src/test/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoopTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,11 @@ class ShuntingLoopTest : KoinTestBase() {
7878

7979
@Test
8080
fun constructor_minimimalContext_throwsException() {
81-
// Load minimal network fixture (only 1 InOut, insufficient for ShuntingLoop)
81+
// Load minimal network fixture (2 InOut nodes but insufficient infrastructure for ShuntingLoop)
8282
val xml = xml("/cz/vutbr/fit/interlockSim/xml/fixtures/minimal-network.xml")
8383
val simContext = createMockSimulationContext(xml)
8484

85-
// ShuntingLoop expects specific vyhybna.xml structure with 2 InOuts, semaphores, switches
85+
// ShuntingLoop expects specific vyhybna.xml structure with 2 InOuts, semaphores, switches, and specific grid coordinates
8686
assertThatBlock { ShuntingLoop(simContext, 60L) }
8787
.withMessage("ShuntingLoop requires specific network structure from vyhybna.xml")
8888
.isFailure()

src/test/kotlin/cz/vutbr/fit/interlockSim/testutil/TestContextBuilder.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,14 @@ class TestContextBuilder {
219219
}
220220

221221
/**
222-
* Creates an empty context with just one InOut for minimal testing.
222+
* Creates a minimal context with two InOut elements (entry and exit).
223+
* Updated to comply with strict validation requiring minimum 2 InOut elements.
223224
*
224-
* @return context with single InOut
225+
* @return context with two InOut elements
225226
*/
226227
fun buildMinimal(): DefaultContext {
227228
return getKoin().get<TestContextBuilder>()
228-
.withInOut("A", 1, 1, false)
229+
.withInOut("A", 1, 1, true) // entry point
230+
.withInOut("B", 2, 1, false) // exit point
229231
.build()
230232
}

src/test/kotlin/cz/vutbr/fit/interlockSim/xml/XMLContextFactoryTest.kt

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import assertk.assertions.isSameInstanceAs
2020
import assertk.assertions.isTrue
2121
import cz.vutbr.fit.interlockSim.context.ContextCreationException
2222
import cz.vutbr.fit.interlockSim.context.DefaultContext
23+
import cz.vutbr.fit.interlockSim.objects.cells.Cell
2324
import cz.vutbr.fit.interlockSim.objects.cells.InOut
2425
import cz.vutbr.fit.interlockSim.objects.cells.RailSemaphore
2526
import cz.vutbr.fit.interlockSim.objects.cells.RailSwitch
@@ -51,12 +52,12 @@ import org.koin.test.inject
5152
* - Edge cases and malformed input
5253
*
5354
* Test Fixtures (src/test/resources/cz/vutbr/fit/interlockSim/xml/fixtures/):
54-
* - minimal-network.xml - Single InOut node
55+
* - minimal-network.xml - Minimal valid network (2 InOut nodes, no tracks)
5556
* - linear-track.xml - Two InOut nodes connected by SimpleTrackBlock
5657
* - switch-basic.xml - RailSwitch with two output tracks
5758
* - semaphore-basic.xml - RailSemaphore between two InOut nodes
5859
* - two-tracks-parallel.xml - Two parallel independent tracks
59-
* - empty-grid.xml - Empty railway network (no cells)
60+
* - empty-grid.xml - Grid with minimal elements (2 InOut nodes, no tracks)
6061
* - invalid-*.xml - Various malformed/invalid XML files
6162
*/
6263
class XMLContextFactoryTest : KoinTestBase() {
@@ -102,17 +103,19 @@ class XMLContextFactoryTest : KoinTestBase() {
102103
@DisplayName("Parsing valid XML fixtures")
103104
inner class ValidXMLParsingTests {
104105
@Test
105-
fun parseXML_minimalNetwork_createsSingleInOut() {
106+
fun parseXML_minimalNetwork_createsTwoInOuts() {
106107
val xml = getFixtureStream("minimal-network.xml")
107108

108109
val context = factory.createContext(xml)
109110

110111
assertThat(context).isNotNull()
111112
val grid = context.getRailWayNetGrid()
112-
val cell = grid.getCellAt(10, 10)
113-
assertThat(cell).isNotNull().isInstanceOf(InOut::class)
114-
val inOut = cell as InOut
115-
assertThat(inOut.getName()).isEqualTo("A")
113+
val cellA = grid.getCellAt(10, 10)
114+
val cellB = grid.getCellAt(20, 10)
115+
assertThat(cellA).isNotNull().isInstanceOf(InOut::class)
116+
assertThat(cellB).isNotNull().isInstanceOf(InOut::class)
117+
assertThat((cellA as InOut).getName()).isEqualTo("A")
118+
assertThat((cellB as InOut).getName()).isEqualTo("B")
116119
}
117120

118121
@Test
@@ -174,7 +177,7 @@ class XMLContextFactoryTest : KoinTestBase() {
174177
}
175178

176179
@Test
177-
fun parseXML_emptyGrid_createsContextWithNoElements() {
180+
fun parseXML_emptyGrid_createsContextWithMinimalElements() {
178181
val xml = getFixtureStream("empty-grid.xml")
179182

180183
val context = factory.createContext(xml)
@@ -183,17 +186,20 @@ class XMLContextFactoryTest : KoinTestBase() {
183186
val grid = context.getRailWayNetGrid()
184187
assertThat(grid.getCols()).isEqualTo(50)
185188
assertThat(grid.getRows()).isEqualTo(50)
189+
// Should have 2 InOut elements (minimum required)
190+
assertThat(context.getInOuts().size).isEqualTo(2)
186191
}
187192

188193
@Test
189-
fun parseXML_emptyGrid_hasEmptyGraph() {
194+
fun parseXML_emptyGrid_hasNoTracks() {
190195
val xml = getFixtureStream("empty-grid.xml")
191196

192197
val context = factory.createContext(xml)
193198

194-
assertThat(context.getGraph().nodeSet())
195-
.withMessage("Empty grid should have empty graph")
196-
.isEmpty()
199+
// Grid has InOut nodes but no track connections
200+
assertThat(context.getGraph().entrySet().size)
201+
.withMessage("Empty grid should have no track connections")
202+
.isEqualTo(0)
197203
}
198204

199205
@Test
@@ -474,6 +480,10 @@ class XMLContextFactoryTest : KoinTestBase() {
474480
// Create empty context
475481
val emptyContext = factory.createEmptyContext()
476482

483+
// Add minimum required InOut elements to satisfy validation
484+
emptyContext.putCell(Point(1, 1), InOut("ENTRY", true, Cell.SpatialType.HORIZONTAL))
485+
emptyContext.putCell(Point(2, 1), InOut("EXIT", false, Cell.SpatialType.HORIZONTAL))
486+
477487
// Save to file
478488
factory.saveContext(emptyContext, tempFile!!)
479489

@@ -568,6 +578,8 @@ class XMLContextFactoryTest : KoinTestBase() {
568578
"<?xml version=\"1.0\"?>\n" +
569579
"<!DOCTYPE net>\n" +
570580
"<net X=\"500\" Y=\"500\">\n" +
581+
" <InOut X=\"10\" Y=\"10\" SpatialType=\"HORIZONTAL\" orientation=\"true\" name=\"ENTRY\"/>\n" +
582+
" <InOut X=\"490\" Y=\"490\" SpatialType=\"HORIZONTAL\" orientation=\"false\" name=\"EXIT\"/>\n" +
571583
"</net>"
572584
val stream = ByteArrayInputStream(largeGridXML.toByteArray())
573585

@@ -584,16 +596,18 @@ class XMLContextFactoryTest : KoinTestBase() {
584596
val minimalGridXML =
585597
"<?xml version=\"1.0\"?>\n" +
586598
"<!DOCTYPE net>\n" +
587-
"<net X=\"1\" Y=\"1\">\n" +
599+
"<net X=\"10\" Y=\"10\">\n" +
600+
" <InOut X=\"1\" Y=\"1\" SpatialType=\"HORIZONTAL\" orientation=\"true\" name=\"ENTRY\"/>\n" +
601+
" <InOut X=\"2\" Y=\"1\" SpatialType=\"HORIZONTAL\" orientation=\"false\" name=\"EXIT\"/>\n" +
588602
"</net>"
589603
val stream = ByteArrayInputStream(minimalGridXML.toByteArray())
590604

591605
val context = factory.createContext(stream)
592606

593607
assertThat(context).isNotNull()
594608
val grid = context.getRailWayNetGrid()
595-
assertThat(grid.getCols()).isEqualTo(1)
596-
assertThat(grid.getRows()).isEqualTo(1)
609+
assertThat(grid.getCols()).isEqualTo(10)
610+
assertThat(grid.getRows()).isEqualTo(10)
597611
}
598612

599613
@Test
@@ -602,6 +616,7 @@ class XMLContextFactoryTest : KoinTestBase() {
602616
"<?xml version=\"1.0\"?>\n" +
603617
"<!DOCTYPE net>\n" +
604618
"<net X=\"100\" Y=\"100\">\n" +
619+
" <InOut X=\"1\" Y=\"1\" SpatialType=\"HORIZONTAL\" orientation=\"true\" name=\"ENTRY\"/>\n" +
605620
" <InOut X=\"98\" Y=\"98\" SpatialType=\"HORIZONTAL\" orientation=\"false\" name=\"CORNER\"/>\n" +
606621
"</net>"
607622
val stream = ByteArrayInputStream(boundaryXML.toByteArray())
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<?xml version="1.0"?>
22
<!DOCTYPE net>
33
<net X="50" Y="50">
4+
<InOut X="10" Y="10" SpatialType="HORIZONTAL" orientation="false" name="A"/>
5+
<InOut X="20" Y="10" SpatialType="HORIZONTAL" orientation="true" name="B"/>
46
</net>

src/test/resources/cz/vutbr/fit/interlockSim/xml/fixtures/minimal-network.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
<!DOCTYPE net>
33
<net X="100" Y="100">
44
<InOut X="10" Y="10" SpatialType="HORIZONTAL" orientation="false" name="A"/>
5+
<InOut X="20" Y="10" SpatialType="HORIZONTAL" orientation="true" name="B"/>
56
</net>

0 commit comments

Comments
 (0)