This document defines the code style and conventions for the interlockSim Java-to-Kotlin migration. The guiding principle is conservative conversion - we're translating Java syntax to Kotlin while preserving the original structure, logic, and readability.
- Philosophy
- Code Style Rules
- Kotlin Conversion Conventions
- Tooling
- Dependency Injection with Koin
- Common Patterns
- Examples
This is not a modernization project. This is a syntax migration from Java to Kotlin with these priorities:
- Preserve Structure - Maintain Java class/method organization
- Preserve Logic - No refactoring or redesign during conversion
- Maintain Readability - Code should be familiar to Java developers
- Enable Gradual Improvement - Kotlin idioms can be introduced later
The interlockSim codebase is a working 2007 BSc thesis project with:
- Complex simulation logic that must remain correct
- Integration with kDisco library (Kotlin Multiplatform, replaces jDisco)
- 237 tests that must continue to pass
- Historical value - preserving original design intent
Rule of thumb: When in doubt, choose the more explicit, Java-like Kotlin syntax.
Tabs, not spaces - Match existing Java code style:
// CORRECT - Uses tabs (width 4)
class Train(val id: Int) {
private val logger = LoggerFactory.getLogger(javaClass)
fun move() {
if (id > 0) {
logger.debug { "Moving train $id" }
}
}
}
// WRONG - Uses spaces
class Train(val id: Int) {
private val logger = LoggerFactory.getLogger(javaClass)
fun move() {
if (id > 0) {
logger.debug { "Moving train $id" }
}
}
}Line length: Maximum 120 characters (matches Java)
Encoding: UTF-8 with LF line endings
Final newline: All files must end with a newline
The .editorconfig file enforces these rules in all editors:
[*.kt]
charset = utf-8
indent_style = tab
indent_size = 4
tab_width = 4
max_line_length = 120Ktlint and Detekt plugins are configured in build.gradle.kts:
- Ktlint 1.5.0 - Code formatting (respects .editorconfig)
- Detekt 1.23.7 - Static analysis (conservative rules in detekt.yml)
Explicit nullable types - Be deliberate about nullability:
// GOOD - Clear nullability
val context: Context? = null // Nullable
val railway: Railway = getRailway() // Non-null
// AVOID - Platform types from Java
val result = javaMethod() // Type is String! (platform type)
// BETTER - Explicit type
val result: String? = javaMethod() // Explicitly nullableSafe calls over !! - Use safe call operator unless absolutely certain:
// GOOD
val length = train?.length ?: 0.0
// ACCEPTABLE with justification
val length = train!!.length // train is guaranteed non-null here by assertion check above
// WRONG - No justification
val length = train!!.lengthConvert fields to properties - Use Kotlin properties instead of Java fields:
// Java
private final double length;
private TrackOccupant in;
public double getLength() { return length; }
public TrackOccupant getIn() { return in; }
public void setIn(TrackOccupant value) { in = value; }
// Kotlin - Properties
private val length: Double // val = immutable (final)
private var `in`: TrackOccupant? // var = mutable, backticks for keyword
// Note: No explicit getters/setters needed unless custom logicPrefer val over var - Use immutable variables when possible:
// GOOD
val maxSpeed = 100.0 // Cannot be reassigned
val trains = mutableListOf<Train>() // Reference is immutable, content mutable
// ACCEPTABLE - Variable needs to change
var currentSpeed = 0.0 // Must be var if value changesPrimary constructor in declaration - Use Kotlin's concise constructor syntax:
// Java
public class SimpleTrack extends AbstractTrack {
private final PathSeparator end1;
private final PathSeparator end2;
private final double length;
public SimpleTrack(PathSeparator end1, PathSeparator end2, double length) {
this.end1 = end1;
this.end2 = end2;
this.length = length;
}
}
// Kotlin - Primary constructor
class SimpleTrack(
private val end1: PathSeparator,
private val end2: PathSeparator,
private val length: Double
) : AbstractTrack() {
// Body
}Explicit types for public API - Be explicit at boundaries:
// Public API - EXPLICIT types
fun getTrains(): List<Train> = trains.toList()
val maxSpeed: Double = 100.0
// Local variables - INFERENCE okay
val count = trains.size // Type inferred as Int
val name = "Locomotive" // Type inferred as StringSpecify mutability explicitly - Make collection mutability clear:
// Immutable (read-only)
val trainList: List<Train> = listOf(train1, train2)
val trainSet: Set<Train> = setOf(train1, train2)
val trainMap: Map<Int, Train> = mapOf(1 to train1, 2 to train2)
// Mutable
val trainList: MutableList<Train> = mutableListOf()
val trainSet: MutableSet<Train> = mutableSetOf()
val trainMap: MutableMap<Int, Train> = mutableMapOf()
// From Java - Preserve behavior
// Java: List<Train> trains = new ArrayList<>();
val trains: MutableList<Train> = ArrayList() // Keep Java type if modifiedUse string templates instead of concatenation:
// Java
logger.info("Train " + train.getId() + " at position " + position);
// Kotlin - String templates
logger.info("Train ${train.id} at position $position")
// Complex expressions - Use braces
logger.info("Train ${train.getId()} speed ${train.getSpeed()}")Use data classes for simple value objects:
// Java
public class TrainInfo {
private final int id;
private final String name;
public TrainInfo(int id, String name) {
this.id = id;
this.name = name;
}
public int getId() { return id; }
public String getName() { return name; }
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
@Override
public String toString() { /* ... */ }
}
// Kotlin - Data class
data class TrainInfo(
val id: Int,
val name: String
)
// equals(), hashCode(), toString(), copy() auto-generatedWhen NOT to use data classes:
- Classes with complex behavior (business logic)
- Classes with mutable state that shouldn't be copied
- Classes that extend other classes (except Any)
- Classes with custom equals/hashCode logic
Use when instead of switch:
// Java
switch (state) {
case FREE:
return true;
case RESERVED:
return from == separator;
case OCCUPIED:
return false;
default:
throw new IllegalStateException("Unknown state: " + state);
}
// Kotlin - when expression
return when (state) {
State.FREE -> true
State.RESERVED -> from == separator
State.OCCUPIED -> false
// No default needed if exhaustive
}Gradually introduce extension functions - Use sparingly during conversion:
// Utility class method (keep during initial conversion)
object TrackUtil {
fun findById(tracks: List<Track>, id: Int): Track? {
return tracks.find { it.id == id }
}
}
// Extension function (introduce later)
fun List<Track>.findById(id: Int): Track? {
return find { it.id == id }
}
// Usage
val track = tracks.findById(5)When adding extension functions to existing data structures, prefer pure Kotlin stdlib over JVM-specific types to ensure compatibility with Kotlin Multiplatform (JS/Native targets).
Example: Array2DMap Pathfinding Extensions (Array2DMapExtensions.kt)
The Array2DMap class provides grid-based spatial representation for railway tracks. Extension functions were added for pathfinding and grid navigation using multiplatform-compatible approaches:
Key Design Principles:
- Avoid JVM-specific types: Use
filter(),minWithOrNull(),maxWithOrNull()instead ofNavigableSet,SortedSet - Lazy evaluation: Use
asSequence()for efficient spatial queries (neighbors, regions) - Pure Kotlin types: All function signatures use stdlib types only
Navigation Extensions:
// Grid boundary queries
fun <V> Array2DMap<V>.firstPoint(): Point? // Smallest point in grid order
fun <V> Array2DMap<V>.lastPoint(): Point? // Largest point in grid order
// Relative navigation (ordered by row first, then column)
fun <V> Array2DMap<V>.higherPoint(point: Point): Point? // Next point after
fun <V> Array2DMap<V>.lowerPoint(point: Point): Point? // Previous point before
fun <V> Array2DMap<V>.ceilingPoint(point: Point): Point? // Point >= reference
fun <V> Array2DMap<V>.floorPoint(point: Point): Point? // Point <= referenceRange Query Extensions:
// Submap views (efficient filtering without copying)
fun <V> Array2DMap<V>.subMap(fromPoint: Point, toPoint: Point): Map<Point, V>
fun <V> Array2DMap<V>.headMap(toPoint: Point): Map<Point, V>
fun <V> Array2DMap<V>.tailMap(fromPoint: Point): Map<Point, V>Pathfinding Extensions:
// Neighbor queries (lazy sequences for efficiency)
fun <V> Array2DMap<V>.neighbors4(point: Point): Sequence<Point> // 4-connected
fun <V> Array2DMap<V>.neighbors8(point: Point): Sequence<Point> // 8-connected
fun <V> Array2DMap<V>.neighborEntries4(point: Point): Sequence<Pair<Point, V>>
fun <V> Array2DMap<V>.neighborEntries8(point: Point): Sequence<Pair<Point, V>>
// Spatial queries
fun <V> Array2DMap<V>.pointsWithinManhattan(point: Point, distance: Int): Sequence<Point>
fun <V> Array2DMap<V>.pointsInRegion(minX: Int, minY: Int, maxX: Int, maxY: Int): Sequence<Point>Multiplatform Implementation Example:
// GOOD - Multiplatform-compatible (pure Kotlin)
fun <V> Array2DMap<V>.higherPoint(point: Point): Point? {
val comparator = Array2DMap.POINT_COMPARATOR
return keys.filter { comparator.compare(it, point) > 0 }
.minWithOrNull(comparator) // Pure Kotlin stdlib
}
// AVOID - JVM-only (breaks JS/Native compatibility)
fun <V> Array2DMap<V>.higherPoint(point: Point): Point? {
val sorted = keys.toSortedSet() // NavigableSet - JVM-specific
return sorted.higher(point) // Not available in Kotlin/JS
}Lazy Evaluation for Performance:
// Returns Sequence (lazy) not List (eager)
fun <V> Array2DMap<V>.neighbors4(point: Point): Sequence<Point> =
sequence {
val candidates = listOf(
Point(point.x, point.y - 1), // up
Point(point.x, point.y + 1), // down
Point(point.x - 1, point.y), // left
Point(point.x + 1, point.y) // right
)
for (candidate in candidates) {
if (containsKey(candidate)) {
yield(candidate) // Lazy generation
}
}
}
// Usage in pathfinding (only evaluates needed neighbors)
val adjacentCells = grid.neighbors4(currentPoint)
.filter { !visited.contains(it) }
.take(1) // Stops after first matchUse Cases:
- A* pathfinding algorithm (see PR #74 context)
- Dijkstra's shortest path
- Grid traversal and region queries
- Railway track connectivity analysis
For complete API reference, see src/main/kotlin/cz/vutbr/fit/interlockSim/util/Array2DMapExtensions.kt.
Use scope functions judiciously - Don't overuse:
// GOOD - Clear intent
val train = Train(id, length, speed).apply {
setPosition(x, y)
setVelocity(v)
}
// GOOD - Null safety
train?.let { t ->
t.move()
t.updatePosition()
}
// BAD - Too complex, hard to read
train?.let {
it.apply {
position = getPosition()?.let { p ->
p.apply {
x += velocity * time
}
}
}
}Nesting limit: Maximum 2 levels of scope functions
Convert static members:
// Java
public class Main {
public static final String PROGRAM_NAME = "InterlockSim";
private static final Logger logger = LoggerFactory.getLogger(Main.class);
public static void main(String[] args) { /* ... */ }
}
// Kotlin - Companion object
class Main {
companion object {
const val PROGRAM_NAME = "InterlockSim" // const for primitive/String constants
private val logger = LoggerFactory.getLogger(Main::class.java)
@JvmStatic
fun main(args: Array<String>) { /* ... */ }
}
}@JvmStatic annotation - Use when Java code needs to call static methods
Use trailing lambda syntax:
// Java
trains.forEach(new Consumer<Train>() {
@Override
public void accept(Train train) {
train.move();
}
});
// Kotlin - Trailing lambda
trains.forEach { train ->
train.move()
}
// Or with implicit 'it' for single parameter
trains.forEach {
it.move()
}Maintain clean Java interop - kDisco is Kotlin Multiplatform and the project retains Java callers:
// Use @JvmStatic for static methods called from Java
companion object {
@JvmStatic
fun getInstance(): Main = instance
}
// Use @JvmField for public fields accessed from Java
companion object {
@JvmField
val PROGRAM_NAME = "InterlockSim"
}
// Use @JvmOverloads for default parameters
@JvmOverloads
fun createTrack(
length: Double,
maxSpeed: Double = 100.0
): Track { /* ... */ }Ktlint enforces consistent code formatting based on .editorconfig.
Run ktlint check:
./gradlew ktlintCheckAuto-fix formatting issues:
./gradlew ktlintFormatAdd pre-commit hook (optional):
./gradlew addKtlintCheckGitPreCommitHookOutput: Reports in multiple formats
- Console output (plain text)
build/reports/ktlint/- Checkstyle XML and HTML reports
Detekt performs static code analysis configured in detekt.yml.
Run detekt:
./gradlew detektGenerate baseline (ignore existing issues):
./gradlew detektBaselineOutput: Reports in multiple formats
- Console output
build/reports/detekt/detekt.html- HTML reportbuild/reports/detekt/detekt.xml- XML reportbuild/reports/detekt/detekt.txt- Text report
- Respects
.editorconfig(tabs, 120 char lines) - Standard Kotlin rules without experimental features
- No Android-specific rules
- Complexity: Increased thresholds for legacy code
- CyclomaticComplexMethod: 20 (default 15)
- LongMethod: 100 lines (default 60)
- LargeClass: 800 lines (default 600)
- Style: Minimal enforcement
- Allow tabs (NoTabs: false)
- Allow both var and val during conversion
- Don't enforce Kotlin idioms during initial conversion
- Naming: Standard Kotlin conventions
- Potential Bugs: Enabled for critical issues
- Comments: No documentation requirements during migration
See detekt.yml for complete configuration with detailed comments.
InterlockSim uses Koin 3.5.6 for dependency injection - a lightweight (~1MB), Kotlin-native DI framework. Koin provides clean dependency management without static fields, code generation, or AOP/proxies.
Key Benefits:
- ✅ Eliminates companion object proliferation
- ✅ Simplifies testing with Mockito integration
- ✅ Zero overhead (direct instantiation)
- ✅ Future-proof (compatible with DSOL/Kalasim migration)
// Define dependencies in module
val myModule = module {
single<XMLContextFactory> { XMLContextFactory() } // Singleton
single<SimulationProcessFactory> { DefaultSimulationProcessFactory() } // Factory for processes
// Note: Do NOT inject contexts as singletons - they need fresh state
// Instead, inject factories that create contexts
}
// Start Koin in Main.kt
fun main(args: Array<String>) {
startKoin {
modules(interlockSimModule)
}
// ... existing main logic
}
// Inject dependencies - Property delegation (preferred)
class MyClass {
private val factory: XMLContextFactory by inject()
}
// Or constructor injection
class MyClass(private val factory: XMLContextFactory)
// Testing with Koin
@Test
fun myTest() {
startKoin {
modules(module {
single<MyDependency> { mock() }
})
}
// Test code
}
@AfterEach
fun tearDown() {
stopKoin() // CRITICAL: Always cleanup in tests
}Rule: Koin injection is allowed in sim/ package — kDisco Phase 1 migration complete (2026-03-20).
Rationale: Physics calculations and simulation correctness must still be validated thoroughly after DI changes; the DI restriction itself is lifted.
// Prefer constructor injection in sim/ (easier to test and reason about)
class Train(private val context: SimulationContext) // ✅ Constructor parameter
// Property injection also allowed
class Train {
private val context: SimulationContext by inject() // ✅ Allowed since 2026-03-20
}Rule: Always inject factories, never contexts directly.
Rationale: Contexts must be fresh instances to prevent state bleeding between simulation runs.
// WRONG - Context as singleton
val contextModule = module {
// ❌ Don't do this - contexts need fresh state per simulation
single<SimulationContext> {
DefaultSimulationContext(100, 100, get<SimulationProcessFactory>())
}
}
// CORRECT - Factory pattern preserved
val contextModule = module {
single<SimulationProcessFactory> { DefaultSimulationProcessFactory() }
single<SimulationContextFactory> { XMLContextFactory() } // ✅ Factory is singleton
}
// Usage
private val factory: SimulationContextFactory by inject()
val context = factory.createContext(file) // Fresh instance each timeRule: Inject factories for objects that need parameterized construction.
// Module definition
val contextModule = module {
// XMLContextFactory as singleton (stateless)
single<XMLContextFactory> { XMLContextFactory() }
// Interface bindings to XMLContextFactory
single<EditingContextFactory> { get<XMLContextFactory>() }
single<SimulationContextFactory> { get<XMLContextFactory>() }
}// Define in module
val myModule = module {
single<XMLContextFactory> { XMLContextFactory() }
}
// Use in code - Property delegation
class MyClass {
private val factory: XMLContextFactory by inject()
}
// Or constructor injection
class MyClass(private val factory: XMLContextFactory)// Define in module - Example of parameterized factory (not currently used in interlockSim)
val myModule = module {
factory<SimulationContext> { (cols: Int, rows: Int) ->
DefaultSimulationContext(cols, rows, get<SimulationProcessFactory>())
}
}
// Use in code with parameters
val context: SimulationContext = get { parametersOf(100, 100) }
// NOTE: In interlockSim, we use XMLContextFactory instead of Koin factory pattern
// because contexts need complex initialization from XML files// Define scope - Example pattern (not currently used for contexts)
val myModule = module {
scope<SimulationScope> {
scoped<SimulationContext> {
DefaultSimulationContext(100, 100, get<SimulationProcessFactory>())
}
}
}
// Use scope
val scope = getKoin().createScope<SimulationScope>()
val context = scope.get<SimulationContext>()
// ... use context
scope.close() // Close scope when doneclass MockSimulationContext : SimulationContext {
private val delegate: DefaultSimulationContext
// ... 260+ lines of manual delegation
}
@Test
fun testShuntingLoop() {
val mock = MockSimulationContext()
val loop = ShuntingLoop(mock)
}@Test
fun testShuntingLoop() {
startKoin {
modules(module {
single<SimulationContext> { mock() }
})
}
val context: SimulationContext = get()
whenever(context.time()).thenReturn(10.0)
val loop: ShuntingLoop = get()
verify(context, times(1)).run()
}
@AfterEach
fun tearDown() {
stopKoin() // CRITICAL: Always cleanup
}org.koin.core.error.NoBeanDefFoundException: No definition found
Solution: Ensure startKoin { modules(...) } is called in Main.kt before any inject() usage.
org.koin.core.error.KoinAppAlreadyStartedException
Solution: Stop Koin in test teardown: stopKoin() in @AfterEach.
org.koin.core.error.InstanceCreationException: Could not create instance
Solution: Refactor to break circular dependency or use lazy<T>() instead of get<T>().
Symptoms: Tests fail intermittently, context data persists between tests.
Solution: Always inject factories, not contexts. Verify stopKoin() in @AfterEach.
InterlockSim DI modules are defined in src/main/kotlin/cz/vutbr/fit/interlockSim/di/InterlockSimModule.kt:
- utilModule - Utility classes (ready for expansion)
- xmlModule - XML parsing, XMLContextFactory
- contextModule - Context lifecycle management
- objectsModule - Domain model (minimal by design)
- guiModule - Swing components (ready for expansion)
- sim/ - ✅ ALLOWED (kDisco Phase 1 migration complete 2026-03-20)
Note: Objects module is intentionally minimal. Domain objects (RailSwitch, RailSemaphore, tracks) are created optimally via XML reflection. Only extend when runtime construction is needed (e.g., Goal 16: Signal Explanation UI).
- Koin Documentation: https://insert-koin.io/docs/reference/koin-core/
- Koin GitHub: https://github.com/InsertKoinIO/koin
- Project Status: See CLAUDE.md "Dependency Injection with Koin" section
// Java
private static final Logger logger = LoggerFactory.getLogger(ClassName.class);
// Kotlin - Companion object
companion object {
private val logger = LoggerFactory.getLogger(ClassName::class.java)
}
// Kotlin - Property (if logger is per-instance)
private val logger = LoggerFactory.getLogger(javaClass)// Java style (still valid)
logger.debug("Train {} at position {}", train.id, position)
// Kotlin style (lazy evaluation)
logger.debug { "Train ${train.id} at position $position" }
// With complex computation (lambda prevents unnecessary work)
logger.debug { "State: ${computeExpensiveState()}" }// Java
assert in == null;
assert state != null : "state is null";
// Kotlin
assert(in == null)
assert(state != null) { "state is null" }
// Or use require/check for better errors
require(length > 0) { "length must be positive" }
check(state != null) { "state is null" }// Java - Non-static inner class
public class Outer {
private class Inner {
// Has implicit reference to Outer
}
}
// Kotlin - Inner class (has outer reference)
class Outer {
inner class Inner {
// Has implicit reference to Outer via this@Outer
}
}
// Kotlin - Nested class (no outer reference, like Java static)
class Outer {
class Nested {
// No reference to Outer
}
}// Java
public enum State {
FREE, RESERVED, OCCUPIED;
}
// Kotlin
enum class State {
FREE,
RESERVED,
OCCUPIED
}
// With properties
enum class State(val displayName: String) {
FREE("Free"),
RESERVED("Reserved"),
OCCUPIED("Occupied")
}// Java
public abstract class AbstractTrack implements Track {
public abstract double length();
public void printInfo() {
System.out.println("Track length: " + length());
}
}
// Kotlin
abstract class AbstractTrack : Track {
abstract fun length(): Double
fun printInfo() {
println("Track length: ${length()}")
}
}Original Java (simplified):
package cz.vutbr.fit.interlockSim.objects.tracks;
import java.util.IdentityHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import cz.vutbr.fit.interlockSim.objects.paths.PathSeparator;
public abstract class SimpleTrack extends AbstractTrack
implements TrackSection, TrackFacility {
private static final Logger logger = LoggerFactory.getLogger(SimpleTrack.class);
private final IdentityHashMap<PathSeparator, Double> speeds = new IdentityHashMap<>();
private final double length;
private final PathSeparator[] ends;
private TrackOccupant in;
private PathSeparator from;
private State state = State.FREE;
public SimpleTrack(PathSeparator end1, PathSeparator end2,
double length, double maxSpeed1, double maxSpeed2) {
super();
if (length < MIN_LENGTH || maxSpeed1 < MINIMAL_MAX_SPEED ||
maxSpeed2 < MINIMAL_MAX_SPEED) {
throw new IllegalArgumentException("length or maxspeed is very small");
}
this.length = length;
this.ends = new PathSeparator[]{end1, end2};
this.speeds.put(end2, maxSpeed2);
this.speeds.put(end1, maxSpeed1);
}
public void enter(TrackOccupant occupant) {
if (logger.isInfoEnabled()) {
logger.info("Block {} ENTRY: occupant={}, state={}->OCCUPIED",
this, occupant, state);
}
assert in == null;
in = occupant;
from = null;
}
public boolean isFreeFrom(PathSeparator seg) {
final boolean isFree = state == State.FREE;
if (logger.isDebugEnabled()) {
logger.debug("Track {} isFreeFrom: from={}, state={}, result={}",
this, seg, state, isFree);
}
return isFree;
}
@Override
public double length() {
return length;
}
}Converted Kotlin (structure-preserving):
package cz.vutbr.fit.interlockSim.objects.tracks
import java.util.IdentityHashMap
import org.slf4j.LoggerFactory
import cz.vutbr.fit.interlockSim.objects.paths.PathSeparator
abstract class SimpleTrack(
end1: PathSeparator,
end2: PathSeparator,
private val length: Double,
maxSpeed1: Double,
maxSpeed2: Double
) : AbstractTrack(), TrackSection, TrackFacility {
companion object {
private val logger = LoggerFactory.getLogger(SimpleTrack::class.java)
}
private val speeds: IdentityHashMap<PathSeparator, Double> = IdentityHashMap()
private val ends: Array<PathSeparator>
private var `in`: TrackOccupant? = null
private var from: PathSeparator? = null
private var state: State = State.FREE
init {
require(length >= MIN_LENGTH) { "length is very small" }
require(maxSpeed1 >= MINIMAL_MAX_SPEED) { "maxSpeed1 is very small" }
require(maxSpeed2 >= MINIMAL_MAX_SPEED) { "maxSpeed2 is very small" }
this.ends = arrayOf(end1, end2)
this.speeds[end2] = maxSpeed2
this.speeds[end1] = maxSpeed1
}
fun enter(occupant: TrackOccupant) {
if (logger.isInfoEnabled) {
logger.info("Block {} ENTRY: occupant={}, state={}->OCCUPIED",
this, occupant, state)
}
assert(`in` == null)
`in` = occupant
from = null
}
fun isFreeFrom(seg: PathSeparator): Boolean {
val isFree = state == State.FREE
if (logger.isDebugEnabled) {
logger.debug("Track {} isFreeFrom: from={}, state={}, result={}",
this, seg, state, isFree)
}
return isFree
}
override fun length(): Double {
return length
}
}Key conversion decisions:
- Primary constructor parameters (end1, end2, length, etc.)
inbecame`in`(backticks for keyword)final→val, mutable fields →var- Nullable types:
inandfromare?(can be null) - Logger in companion object (static equivalent)
IllegalArgumentException→require()(more Kotlin-idiomatic)- Preserved method structure and logging exactly
Original Java (simplified):
package cz.vutbr.fit.interlockSim.util;
import java.util.AbstractSet;
import java.util.Iterator;
import java.util.NoSuchElementException;
/**
* @deprecated should be replaced in kotlin with library thing like Pair
*/
public class Doubleton<T, V> extends AbstractSet<T> {
enum IteratorState {
INIT, FIRST, SECOND;
}
final class DoubletonIterator implements Iterator<T> {
IteratorState state = IteratorState.INIT;
public boolean hasNext() {
assert state != null;
return state != IteratorState.SECOND;
}
public T next() {
assert state != null;
if (state == IteratorState.INIT) {
state = IteratorState.FIRST;
return first;
} else if (state == IteratorState.FIRST) {
state = IteratorState.SECOND;
return second;
} else {
throw new NoSuchElementException();
}
}
public void remove() {
throw new UnsupportedOperationException();
}
}
private final T first;
private final T second;
private final V info;
public Doubleton(T first, T second, V info) {
this.first = first;
this.second = second;
this.info = info;
}
public T getFirst() { return first; }
public T getSecond() { return second; }
public V getInfo() { return info; }
@Override
public Iterator<T> iterator() {
return new DoubletonIterator();
}
@Override
public int size() {
return 2;
}
}Converted Kotlin (structure-preserving):
package cz.vutbr.fit.interlockSim.util
import java.util.NoSuchElementException
/**
* @deprecated should be replaced in kotlin with library thing like Pair
*/
@Deprecated("Use Kotlin Pair or data class instead")
class Doubleton<T, V>(
private val first: T,
private val second: T,
private val info: V
) : AbstractSet<T>() {
enum class IteratorState {
INIT,
FIRST,
SECOND
}
inner class DoubletonIterator : Iterator<T> {
private var state: IteratorState = IteratorState.INIT
override fun hasNext(): Boolean {
check(state != null)
return state != IteratorState.SECOND
}
override fun next(): T {
check(state != null)
return when (state) {
IteratorState.INIT -> {
state = IteratorState.FIRST
first
}
IteratorState.FIRST -> {
state = IteratorState.SECOND
second
}
IteratorState.SECOND -> throw NoSuchElementException()
}
}
}
fun getFirst(): T = first
fun getSecond(): T = second
fun getInfo(): V = info
override fun iterator(): Iterator<T> {
return DoubletonIterator()
}
override val size: Int
get() = 2
}Key conversion decisions:
- Primary constructor with properties
finalinner class →inner class(needs outer reference)- Enum stays enum class
next()method: if-else chain → when expression (more Kotlin-idiomatic)size()method →sizeproperty override- Preserved @Deprecated (kept Java comment, added Kotlin annotation)
assert→check()(throws IllegalStateException)
- Match existing Java code style (tabs, line length)
- Use explicit types for public APIs
- Be explicit about nullability (?, !!)
- Convert utility methods to extension functions gradually
- Use data classes for simple value objects
- Use when expressions instead of if-else chains
- Use string templates instead of concatenation
- Preserve structure and logic during conversion
- Don't use spaces (use tabs)
- Don't exceed 120 character line length
- Don't over-use scope functions (max 2 levels deep)
- Don't use !! without justification
- Don't refactor or redesign during conversion
- Don't introduce advanced Kotlin features without careful consideration
- Don't break Java interoperability (kDisco and legacy APIs)
- Choose the more explicit, Java-like Kotlin syntax
- Preserve the original structure
- Ask for clarification before making significant changes
- Remember: This is a syntax migration, not a modernization
The interlockSim project uses a dual-level approach to code quality enforcement, recognizing the difference between legacy converted code and new Kotlin code.
Applies to: All existing Kotlin files converted from Java
Configuration: detekt.yml (default)
Rules: Permissive - focuses on critical bugs, allows legacy patterns
Philosophy: Preserve Java structure, avoid forcing Kotlin idioms on converted code
Run:
./gradlew detektCharacteristics:
- Allows var usage (mutability)
- Permits complex methods and classes (higher thresholds)
- No enforcement of Kotlin idioms (data classes, expression syntax)
- Flexible exception handling
- Optional documentation
- TAB INDENTATION (matches original Java code)
Applies to: New Kotlin files written from scratch (not Java conversions)
Configuration: detekt-strict.yml
Rules: Strict - enforces Kotlin best practices, immutability, documentation
Philosophy: New code should follow modern Kotlin idioms and best practices
Location Markers:
- Place new Kotlin code in
src/main/kotlin/**/new/subdirectory - Example:
src/main/kotlin/cz/vutbr/fit/interlockSim/new/MyNewClass.kt
Run:
./gradlew detektStrictEnforced Rules:
- Immutability: Prefer
valovervar(VarCouldBeVal) - Null Safety: No unsafe calls or casts (UnsafeCallOnNullableType, UnsafeCast)
- Documentation: Public APIs must be documented (UndocumentedPublicClass/Function)
- Complexity: Lower thresholds - max 10 cyclomatic complexity, 60 line methods
- Kotlin Idioms:
- Use data classes where appropriate
- Prefer expression body syntax
- Extract magic numbers to constants
- Use require/check for preconditions
- Prefer immutable collections
- Code Quality:
- No unused private members
- Limit return statements (max 2)
- Use safe casts
- Prefer structural equality over referential
- Exception Handling:
- No printStackTrace
- Require exception messages
- No generic exception catching/throwing
- Same TAB INDENTATION as legacy code for consistency
As the codebase matures:
- Phase 1 (Current): All code uses permissive rules (detekt.yml)
- Phase 2: Write new Kotlin features in
new/directories with strict rules - Phase 3: Gradually refactor legacy code to meet strict rules
- Phase 4: Eventually migrate all code to strict rules
CRITICAL: Both permissive and strict configurations use tabs for indentation to maintain consistency with the original Java code style.
.editorconfig:indent_style = tab,indent_size = 4,max_line_length = 120detekt.yml:NoTabs: active: false,Indentation: active: false(allows tabs, lets ktlint use .editorconfig)detekt-strict.yml:NoTabs: active: false,Indentation: active: false(same as permissive)- Ktlint: Respects
.editorconfigautomatically
Why tabs? The original Java code from the develop branch uses tab indentation with 4-space visual width. To maintain consistency across the codebase and simplify the migration, all Kotlin code (both legacy and new) uses the same indentation style.
# Check legacy/converted code (permissive rules)
./gradlew detekt
# Check new Kotlin code (strict rules)
./gradlew detektStrict
# Check code formatting (preserves tabs)
./gradlew ktlintCheck
# Auto-format code (preserves tab indentation)
./gradlew ktlintFormat
# Run all checks
./gradlew build # Includes detekt, ktlintCheck, testsTo verify that tab indentation is preserved:
# Show tabs in a Kotlin file (tabs show as ^I)
cat -A src/main/kotlin/cz/vutbr/fit/interlockSim/util/Doubleton.kt | head -20
# Format a file and verify tabs remain
./gradlew ktlintFormat
cat -A <file>.kt | grep '^ ' # Should show leading tabsThe testutil package provides centralized test fixtures and utilities to reduce duplication and improve test maintainability. Always use these fixtures instead of creating inline test data.
TestFixtures provides standardized loading of XML configuration files.
Usage Pattern:
import cz.vutbr.fit.interlockSim.testutil.TestFixtures
@Test
fun testWithShuntingLoop() {
TestFixtures.loadShuntingXml().use { stream ->
val context = factory.createContext(stream)
// Test with context
}
}Available Fixtures:
loadShuntingXml()- Main shunting loop (vyhybna.xml)loadLinearTrackXml()- Simple A→B trackloadMinimalNetworkXml()- Minimal valid networkloadSwitchBasicXml()- Basic switch topologyloadSemaphoreBasicXml()- Basic semaphore configuration- Plus 10+ other fixtures (see TestFixtures.kt)
Benefits:
- ✅ Consistent resource paths (no hardcoded strings)
- ✅ Proper resource management (returns InputStream)
- ✅ Clear error messages when fixtures missing
- ✅ Single source of truth for fixture locations
TestTopologies provides reusable network topologies for tests that don't need XML.
Context Type Variants:
Each topology has two variants:
- EditingContext - For editor/topology tests (e.g.,
simpleLinearPath()) - SimulationContext - For simulation/train tests (e.g.,
simpleLinearPathSimulation())
Example: EditingContext Variant
import cz.vutbr.fit.interlockSim.testutil.TestTopologies
@Test
fun testTopologyNavigation() {
TestTopologies.simpleLinearPath().use { context ->
val navigator = context.getTopologyNavigator()
// Test navigation logic
}
}Example: SimulationContext Variant
@Test
fun testTrainMovement() {
TestTopologies.simpleLinearPathSimulation().use { context ->
val trainService = context.getTrainNavigationService()
// Test train navigation
}
}Available Topologies:
| Topology | EditingContext | SimulationContext | Description |
|---|---|---|---|
| Simple Linear (A→B) | simpleLinearPath() |
simpleLinearPathSimulation() |
100m track, 80 m/s |
| Linear with Semaphore | linearPathWithSemaphore(allowing) |
linearPathWithSemaphoreSimulation(allowing) |
A→[S]→B with signal |
| Dead-End | deadEndSingleInOut() |
deadEndSingleInOutSimulation() |
Single InOut, no connections |
When to Use Which:
- XML Fixtures - For complex real-world topologies (vyhybna.xml, switches with multiple branches)
- TestTopologies - For simple, focused test scenarios (linear paths, single signals)
- TestContextBuilder - Only when you need custom coordinates or unique configurations
All contexts implement AutoCloseable - always use .use {}:
Pattern 1: Single Context
@Test
fun testSingleContext() {
TestTopologies.simpleLinearPath().use { context ->
// Context auto-closes at end of block
}
}Pattern 2: Nested Contexts (XML → Editing → Simulation)
@Test
fun testWithXmlContext() {
TestFixtures.loadShuntingXml().use { xmlStream ->
editingFactory.createContext(xmlStream).use { editingCtx ->
simulationFactory.createContext(editingCtx).use { simCtx ->
// All 3 resources auto-close in reverse order
}
}
}
}Pattern 3: Shared Context (setUp/tearDown)
When context is shared across multiple tests:
class MyTestSuite {
private lateinit var context: SimulationContext
@BeforeEach
fun setUp() {
context = TestTopologies.simpleLinearPathSimulation()
}
@AfterEach
fun tearDown() {
context.close() // Manual close in tearDown
}
@Test
fun test1() {
// Use shared context
}
@Test
fun test2() {
// Use shared context
}
}❌ Don't hardcode XML paths:
// BAD
val file = File("src/main/resources/.../vyhybna.xml")
val stream = javaClass.getResourceAsStream("/cz/.../vyhybna.xml")
// GOOD
val stream = TestFixtures.loadShuntingXml()❌ Don't create duplicate topologies:
// BAD - duplicated 15 times across tests
val context = TestContextBuilder()
.withInOut("A", 1, 1, true)
.withInOut("B", 5, 5, false)
.withConnection(1, 1, 5, 5, 100.0, 80.0)
.buildEditingContext()
// GOOD - reusable fixture
val context = TestTopologies.simpleLinearPath()❌ Don't forget to close contexts:
// BAD - resource leak
val context = factory.createContext(stream)
// ... test code ...
// context never closed!
// GOOD - auto-closes
factory.createContext(stream).use { context ->
// ... test code ...
}❌ Don't mix context types:
// BAD - simulation test using EditingContext
val context = TestTopologies.simpleLinearPath() // Returns EditingContext
context.getTrainNavigationService() // Won't work!
// GOOD - use simulation variant
val context = TestTopologies.simpleLinearPathSimulation()
context.getTrainNavigationService() // Works!Use TestContextBuilder only when TestTopologies doesn't fit:
// When you need custom coordinates or unique topology
val context = TestContextBuilder()
.withInOut("Entry", 2, 3, true) // Custom position
.withInOut("Exit", 8, 9, false)
.withConnection(2, 3, 8, 9, 150.0, 100.0) // Custom length/speed
.buildEditingContext()Need test network?
├─ Is it vyhybna.xml or other real-world config?
│ └─ Use: TestFixtures.loadShuntingXml()
│
├─ Is it simple A→B, A→[S]→B, or dead-end?
│ ├─ For editor/topology tests?
│ │ └─ Use: TestTopologies.simpleLinearPath()
│ └─ For simulation/train tests?
│ └─ Use: TestTopologies.simpleLinearPathSimulation()
│
└─ Need custom coordinates or unique topology?
└─ Use: TestContextBuilder()
Status: Adopted February 2026. Parameterized tests reduce duplication and improve coverage by running the same assertion logic across multiple inputs.
Use parameterized tests when:
- Testing the same behavior across multiple enum values or input combinations
- Verifying mathematical/geometric operations with known input→output pairs
- Testing matrix combinations (e.g., type × spatialType → expected segments)
Do not use parameterized tests for single-case tests or when each case needs substantially different setup logic.
Tests all values of an enum automatically. New enum values are covered without test changes.
@ParameterizedTest
@EnumSource(Segment::class)
fun `segment transformations work correctly`(segment: Segment) {
assertThat(anti(anti(segment)))
.withMessage("anti(anti($segment)) should be identity")
.isSameInstanceAs(segment)
}Tests a subset of enum values. Use when some values need different treatment.
@ParameterizedTest(name = "transition from STOP to {0}")
@EnumSource(value = Signal::class, names = ["FREE", "S30", "S40", "S60", "S80", "S100"])
fun `semaphore transitions from STOP to other aspects`(targetSignal: Signal) {
assertThat(semaphore.signal).isEqualTo(Signal.STOP)
semaphore.signal = targetSignal
assertThat(semaphore.signal).isEqualTo(targetSignal)
}Best for tabular data. JUnit auto-converts CSV strings to enums, ints, booleans.
@ParameterizedTest(name = "neighbors{0} at ({1},{2}) returns {3} neighbors")
@CsvSource(
"4, 1, 1, 4", // center has 4 neighbors in 4-connectivity
"4, 0, 0, 2", // corner has only 2 neighbors in 4-connectivity
"8, 1, 1, 6", // center has 6 neighbors in 8-connectivity
"8, 0, 0, 3" // corner has 3 neighbors in 8-connectivity
)
fun `neighbor algorithm returns correct count`(
connectivity: Int, x: Int, y: Int, expectedCount: Int
) {
val neighbors = when (connectivity) {
4 -> map.neighbors4(Point(x, y)).toList()
8 -> map.neighbors8(Point(x, y)).toList()
else -> error("Invalid connectivity: $connectivity")
}
assertThat(neighbors.size).isEqualTo(expectedCount)
}For testing a single parameter across multiple values.
@ParameterizedTest(name = "d2r(r2d({0})) = {0}")
@ValueSource(ints = [-5, -1, 0, 1, 5, 10])
fun `d2r and r2d are inverse operations`(d: Int) {
val roundTripped = r2d(d2r(d))
assertThat(roundTripped)
.withMessage("Round-trip conversion for d=$d")
.isEqualTo(d)
}Use when test data is too complex for CSV. Define a @JvmStatic method in a companion object.
companion object {
@JvmStatic
fun cellTypePrefixes() = listOf(
Arguments.of(RailSemaphore::class.java, "S1"),
Arguments.of(RailSwitch::class.java, "SW1"),
Arguments.of(InOut::class.java, "IO1"),
)
}
@ParameterizedTest(name = "first name for {0} is {1}")
@MethodSource("cellTypePrefixes")
fun `generates correct first name for cell type`(
cellClass: Class<out NodeCell>, expectedName: String
) {
val name = AutoNameGenerator.generateName(cellClass, context)
assertThat(name).isEqualTo(expectedName)
}Use for complex matrices reusable across test files. Provider lives in testutil/providers/.
// In testutil/providers/SwitchActiveSegmentsProvider.kt
class SwitchActiveSegmentsProvider : ArgumentsProvider {
override fun provideArguments(context: ExtensionContext): Stream<Arguments> = Stream.of(
Arguments.of(Type.SIMPLE_LEFT_FALSE, SpatialType.HORIZONTAL, Segment.A, Segment.F, Segment.A, Segment.E),
Arguments.of(Type.SIMPLE_RIGHT_FALSE, SpatialType.HORIZONTAL, Segment.A, Segment.F, Segment.A, Segment.G),
// ... more combinations
)
}
// In DynamicRailSwitchTest.kt
@ParameterizedTest(name = "{0}/{1}: MAIN({2},{3}), BRANCH({4},{5})")
@ArgumentsSource(SwitchActiveSegmentsProvider::class)
fun `getActiveSegments returns correct segments for type`(
type: Type, spatialType: SpatialType,
mainSeg1: Segment, mainSeg2: Segment,
branchSeg1: Segment, branchSeg2: Segment
) { /* ... */ }@MethodSource in @Nested inner classes: Must use fully qualified name with # separator.
// WRONG — won't find the method from @Nested inner class:
@MethodSource("cellTypePrefixes")
// CORRECT — FQN syntax:
@MethodSource("cz.vutbr.fit.interlockSim.context.AutoNameGeneratorTest#cellTypePrefixes")Companion must be on outer class when inner @Nested classes reference it.
@CsvSource auto-conversion: String "STOP" auto-converts to Signal.STOP if the parameter type is Signal. No explicit converter needed.
Test display names: Use {0}, {1}, etc. in @ParameterizedTest(name = "...") to reference arguments by position. Complex objects use their toString().
Which parameterized annotation to use?
├─ Testing all/some values of an enum?
│ └─ @EnumSource (with optional names filter)
│
├─ Simple input→output pairs (primitives, enums, strings)?
│ └─ @CsvSource
│
├─ Single parameter, multiple primitive values?
│ └─ @ValueSource
│
├─ Complex objects or data classes as test input?
│ ├─ Used in one test class only?
│ │ └─ @MethodSource (companion @JvmStatic method)
│ └─ Reusable across multiple test classes?
│ └─ @ArgumentsSource (provider in testutil/providers/)
Dependencies are managed via Gradle with fallback strategy:
- kDisco 0.5.0 - Discrete event simulation library (Kotlin Multiplatform, replaces jDisco)
- Repository: https://github.com/bedaHovorka/kdisco
- Published to GitHub Packages:
https://maven.pkg.github.com/bedaHovorka/kdisco - Fallback order:
mavenLocal()(local cache) → GitHub Packages → build fails - Requires GitHub authentication for package download unless installed locally (see below)
- JUnit 5.11.4 - Testing framework (JUnit Jupiter API and Engine)
- AssertK 0.28.1 - Fluent Kotlin assertion library
- MockK 1.13.14 - Kotlin-native mocking framework (supports sealed classes, coroutines)
- Mockito 5.21.0 - Java mocking framework (deprecated, being phased out in favor of MockK)
- kotlin-logging-jvm 7.0.3 - Kotlin logging wrapper (lambda-based lazy evaluation)
- SLF4J 2.0.17 + Logback 1.5.23 - Logging backend (used by kotlin-logging)
- Koin 3.5.6 - Kotlin-native dependency injection framework (adopted 2026-01-12, migration complete)
Gradle automatically downloads dependencies during the build. Configuration files:
build.gradle.kts- Build configuration and dependency declarationssettings.gradle.kts- Project settingsgradle.properties- Version management and build properties
GitHub Packages Authentication:
To download kDisco from GitHub Packages, set these environment variables:
export GITHUB_ACTOR=your-github-username
export GITHUB_TOKEN=your-personal-access-tokenOr create ~/.gradle/gradle.properties:
gpr.user=your-github-username
gpr.key=your-personal-access-tokenNote: In GitHub Actions CI/CD, authentication is automatic via GITHUB_TOKEN.
Offline / Local Maven Fallback:
If GitHub Packages is unreachable or you do not want to use a token, build and install kDisco locally:
# Clone kDisco repository
cd ~/work
git clone https://github.com/bedaHovorka/kdisco.git
cd kdisco
# Build and install to local Maven repository
./gradlew :kdisco-core:publishToMavenLocal
# Return to interlockSim
cd ~/work/interlockSimThen run Gradle normally. Because mavenLocal() is checked before GitHub Packages, the locally published artifacts satisfy the dependency without network authentication.
Verify installation:
ls ~/.m2/repository/cz/hovorka/kdisco/kdisco-core-jvm/0.5.0/
# Should show: kdisco-core-jvm-0.5.0.jar, kdisco-core-jvm-0.5.0.pomClean and build (includes tests and uber JAR):
./gradlew clean buildBuild only (compiles, tests, creates JAR):
./gradlew buildRun tests:
./gradlew test # Unit tests only
./gradlew integrationTest # Integration tests only
./gradlew test integrationTest # All testsCreate uber JAR (all dependencies included):
./gradlew shadowJarRun application:
./gradlew runSim # Pre-configured shunting loop example
./gradlew runEditor # Launch editor GUI
./gradlew runExampleGui # Animated GUI simulation (NEW)
./gradlew runExample -PexampleName=shuntingLoop -PendTime=300 # Custom exampleGenerate JavaDoc documentation:
./gradlew javadocShow dependency tree:
./gradlew dependenciesAfter building with ./gradlew shadowJar, run from the project root:
Simulation mode:
java -jar build/libs/interlockSim.jar sim [xmlFile]Editor mode:
java -jar build/libs/interlockSim.jar edit [xmlFile]Built-in examples (console output):
java -jar build/libs/interlockSim.jar example [exampleName] [endTime]Built-in examples with animated GUI:
java -jar build/libs/interlockSim.jar exampleGui [exampleName] [endTime]
# Example: Run shunting loop with animation for 300 time units
java -jar build/libs/interlockSim.jar exampleGui shuntingLoop 300To list available examples:
java -jar build/libs/interlockSim.jar examplePrerequisites: Docker/Docker Compose and X11 server (Linux: usually installed; macOS: XQuartz; Windows: VcXsrv/Xming)
Docker Services:
- app: Java application with X11 GUI support
- text: LaTeX thesis compilation
Build services:
# Set GitHub credentials for kDisco download
export GITHUB_ACTOR=your-github-username
export GITHUB_TOKEN=your-personal-access-token
# Build app (kDisco downloaded from GitHub Packages or uses local cache)
# GITHUB_TOKEN is passed as a BuildKit secret; it is never interpolated into
# Dockerfile RUN command strings, so it cannot leak into build logs or image history.
# The Gradle build and tests run as an unprivileged builder user (UID/GID 1001).
docker compose build app
# Build thesis
docker compose build textRun editor GUI:
# Method 1 (Recommended): Use .Xauthority file (more secure)
docker compose up app
# Method 2: If you get authorization errors, allow X11 connections from Docker
xhost +local:docker
docker compose up app
# When done with Method 2, revoke access for security:
xhost -local:dockerRun simulation example:
docker compose run app java -jar interlockSim.jar example shuntingLoop 60Run simulation with custom XML:
docker compose run -v $(pwd)/myfile.xml:/app/myfile.xml app java -jar interlockSim.jar sim myfile.xmlBuild thesis PDF:
docker compose up text
# PDF will be available in artifacts/text/bakalarka.pdfExtract compiled JAR:
docker compose build app
# JAR will be available in artifacts/app/interlockSim.jarDocker Architecture:
Multi-stage Dockerfile: Builder stage (Temurin 21 JDK, compiles, tests, creates uber JAR) → Runner stage (Temurin 21 JRE, X11 support). text/Dockerfile for LaTeX thesis compilation (Debian Bookworm, TeX Live).
X11 Forwarding Troubleshooting:
If you encounter java.awt.AWTError: Can't connect to X11 window server:
-
Check DISPLAY variable:
echo $DISPLAY # Should show :0, :1, or :0.0
-
Verify X11 is running:
xdpyinfo | head # Should show display information
-
Use xhost as fallback:
xhost +local:docker docker compose up app # When done: xhost -local:docker -
Check .Xauthority permissions:
ls -la ~/.Xauthority # File should exist and be readable
-
For Wayland users (Fedora 43+):
# Wayland uses a different socket location export DISPLAY=:0 # Or set XDG_SESSION_TYPE=x11 to force X11 session
-
For Fedora with SELinux (Fedora 43+):
# If you encounter AVC denial errors, configure SELinux: # See docs/FEDORA_DOCKER_X11_SETUP.md for detailed instructions # Option 1: Use pre-generated policy (fastest) sudo semodule -i desktop-ui/docker-x11/docker-x11-complete.pp # Option 2: Generate from audit log xhost +local:docker sudo ausearch -c 'java' --raw | sudo audit2allow -M docker-x11-complete sudo semodule -i docker-x11-complete.pp
Artifacts:
Build outputs copied to ./artifacts/: app/interlockSim.jar, text/bakalarka.pdf
InterlockSim uses kotlin-logging (SLF4J wrapper) with Logback backend. Configuration: src/main/resources/logback.xml
import io.github.oshai.kotlinlogging.KotlinLogging
private val logger = KotlinLogging.logger {}
logger.debug { "Message with $variable" } // Lambda-based lazy evaluation
logger.info { "Train ${train.id} at position $position" }
logger.warn { "State: ${computeExpensiveState()}" } // Lambda prevents unnecessary work// Old style (always evaluates arguments)
logger.debug("State: " + computeExpensiveState()) // computeExpensiveState() called even if debug disabled
// New style (lazy evaluation)
logger.debug { "State: ${computeExpensiveState()}" } // computeExpensiveState() only called if debug enabledEdit logback.xml:
<configuration>
<root level="DEBUG"> <!-- Change to DEBUG, INFO, WARN, ERROR -->
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
</configuration>Runtime override (without editing XML):
# From command line
java -Dlogback.level=DEBUG -jar interlockSim.jar ...
# From Docker
docker compose run -e ROOT_LOG_LEVEL=DEBUG app java -jar interlockSim.jar ...Default output:
- Console output (stdout)
- File output:
logs/interlockSim.log
Log format:
2026-02-05 10:30:45.123 [main] DEBUG c.v.f.i.TrackSection - Block A ENTRY: occupant=Train1, state=FREE->OCCUPIED
SonarQube integration provides: code smells, security vulnerabilities, coverage (JaCoCo), duplication, complexity metrics, technical debt.
Configuration files:
build.gradle.kts- SonarQube plugin and JaCoCo configuration (primary)sonar-project.properties- Additional SonarQube settings (optional).github/workflows/sonarqube.yml- CI/CD integration for automated analysis
SonarCloud (recommended): Sign up at https://sonarcloud.io, generate token, run:
./gradlew clean test jacocoTestReport sonar \
-Dsonar.host.url=https://sonarcloud.io \
-Dsonar.organization=<your-org> \
-Dsonar.token=<your-token>Local server: docker run -d -p 9000:9000 sonarqube:lts-community, then use -Dsonar.host.url=http://localhost:9000
Generate with ./gradlew test jacocoTestReport. View HTML at build/reports/jacoco/test/html/index.html. Configure thresholds in build.gradle.kts.
Quality gates permissive by default. Enable strict: sonar.qualitygate.wait=true. CI/CD via .github/workflows/sonarqube.yml (requires SONAR_TOKEN/SONAR_ORGANIZATION secrets).
Conservative approach differentiated by component type:
Simulation Core (sim/ package):
- Minimal changes only - Be extremely conservative with simulation logic
- No refactoring - Do not restructure working simulation code
- Tests required - Any changes MUST have comprehensive test coverage first
- No unsolicited improvements - Only make explicitly requested changes
- No hallucinated solutions - Bugfixes must reference working tag behavior with minimal diffs; no speculative spaghetti code
- Koin injection allowed - The Koin restriction was lifted 2026-03-20 (kDisco Phase 1 complete)
kDisco Library:
- Do not modify - kDisco is maintained as a separate project at https://github.com/bedaHovorka/kdisco
- Report issues at the kDisco repository
GUI (gui/ package), Editor, Utilities, Context System:
- Modernization allowed - Can refactor, improve, and apply Kotlin idioms
- Tests required - Must have test coverage before and after changes
- Alignment required - Changes must align with LONG_TERM_GOALS.md goals and architecture
- Code quality - Apply detekt-strict.yml rules for new Kotlin code
- Rationale: These components can be improved independently without affecting simulation correctness
- Tests are mandatory - Any modified code MUST be covered by tests (before and after)
- Align with goals - Check that changes support or enable LONG_TERM_GOALS.md objectives
- No breaking changes - Maintain backward compatibility with existing XML configurations and APIs
- Document decisions - Update relevant documentation for architectural changes
- Quality gates - All changes must pass:
./gradlew build detekt ktlintCheck test
ALLOWED (with tests):
- Refactoring GUI components for Goal 20 (Accessibility)
- Adding new editor features for Goal 16 (Signal Explanation)
- Improving context serialization for Goal 5 (Save/Restore State)
- Modernizing utility classes with Kotlin idioms
- Adding metrics collection infrastructure for Goal 6
RESTRICTED (sim/ package — conservative approach required):
- Changing Train physics calculations
- Modifying kDisco process scheduling
- Restructuring simulation event handling
- Changing core simulation algorithms
PROHIBITED:
- Changes that break existing XML configurations
- Modifications that fail existing tests
- Changes that conflict with LONG_TERM_GOALS.md
- kDisco library modifications (maintain at https://github.com/bedaHovorka/kdisco)
The interlockSim architecture separates static configuration from dynamic simulation state using the wrapper pattern.
Applies to: InOut, RailSwitch, RailSemaphore, TrackBlock
Key Method: Context.toDynamic(static) → dynamic
Static Objects (Editing Context):
- Immutable configuration set during network design
- Grid position, names, types, connections, speeds
- Used in
EditingContext(mutable operations allowed) - Examples:
InOut,RailSwitch,RailSemaphore,SimpleTrackBlock
Dynamic Wrappers (Simulation Context):
- Mutable simulation state (occupancy, signals, reservations)
- Wraps static object (delegates configuration queries)
- Used in
SimulationContext(immutable network structure) - Examples:
DynamicInOut,DynamicRailSwitch,DynamicRailSemaphore,DynamicTrack
Identity Contract:
- Wrappers use
System.identityHashCode(static)for stable hash - Same static object always returns same wrapper (IdentityHashMap caching)
===reference equality based on wrapped object
Static InOut:
// In editing context (network design)
val editingContext: EditingContext = factory.createContext()
val staticInOut = InOut(
name = "A",
isEntry = false, // Exit point
spatialType = SpatialType.HORIZONTAL
)
editingContext.putCell(Point(11, 8), staticInOut)
// Static configuration queries:
staticInOut.getName() // "A"
staticInOut.isEntry // false
staticInOut.getPoint() // Point(11, 8)Dynamic InOut:
// Transform to simulation context (immutable network)
val simContext = ContextTransformer.createSimulationContext(editingContext, factory)
// Get dynamic wrapper
val dynamicInOut = simContext.toDynamic(staticInOut) as DynamicInOut
// Wrapper delegates configuration to static:
assert(dynamicInOut.name == staticInOut.getName()) // ✓ Same
assert(dynamicInOut.isEntry == staticInOut.isEntry) // ✓ Same
assert(dynamicInOut === simContext.toDynamic(staticInOut)) // ✓ Same wrapper instance
// Wrapper manages simulation state:
dynamicInOut.lastTrain = train // Mutable operation (OK in simulation)Railway Domain Context:
- InOut elements represent network boundaries (train spawn/despawn points)
- 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:
- Immutable editing context - No simulation state during network design
- Mutable simulation context - State changes isolated from configuration
- Clear separation of concerns - Configuration vs runtime state
- Easy context transformation - Editing → simulation conversion
- Stable identity - Object identity preserved across state changes (enables deterministic testing)
Usage Rule: Always use context.toDynamic(element) before state operations in simulation code.
Documented in: docs/STATIC_DYNAMIC_SEPARATION_ARCHITECTURE.md (983 lines, comprehensive pattern documentation)
Three specialized services handle path finding and reservation using scope-per-context pattern:
1. TopologyNavigator - Static topology navigation (pure graph traversal, no state dependencies)
- Interface:
context/navigation/TopologyNavigator - Implementation:
context/navigation/DefaultTopologyNavigator - Use Case: Editor validation, network analysis without dynamic state
- Access:
EditingContext.getTopologyNavigator()orSimulationEnvironment.getTopologyNavigator() - Methods:
findPath(),getNextTrackSection(),findPathToNextSemaphore()
2. PathReservationService - Dispatcher logic (find FREE paths, reserve atomically)
- Interface:
context/navigation/PathReservationService - Implementation:
context/navigation/DefaultPathReservationService - Use Case: Dispatcher finding available routes, interlocking path setup
- Access:
SimulationEnvironment.getPathReservationService() - Features: Atomic reservation, all-or-nothing semantics, TOCTOU race condition fix
- Methods:
reservePath(),releasePath(),findReservablePaths(),reservePathToAnyNextSemaphore()
3. TrainNavigationService - Train-specific navigation (follow RESERVED paths only)
- Interface:
context/navigation/TrainNavigationService - Implementation:
context/navigation/DefaultTrainNavigationService - Use Case: Train requesting next track section (only through owned blocks)
- Access:
SimulationEnvironment.getTrainNavigationService() - Features: Explicit ownership validation, null = "not reserved for THIS train"
- Methods:
findReservedPathForTrain(),isPathReservedForTrain(),getReservedBlocks()
4. PathReservationRegistry - Bidirectional train↔block ownership tracking
- Class:
context/navigation/PathReservationRegistry - Features: O(1) queries, scoped lifetime (one per context), shared by all services
- Methods:
register(),unregister(),getBlocks(),getOwner(),isOwnedBy()
Koin DI Integration:
- Scope-per-context pattern (one registry per context, isolated between contexts)
- Services share ONE registry within context (consistent ownership view)
- Automatic cleanup via
Context.close()(AutoCloseable pattern)
Documentation:
docs/PATH_DISCOVERY_ARCHITECTURE.md- Design rationale, trade-offs, implementation phases (808 lines)docs/PATH_DISCOVERY_MIGRATION_GUIDE.md- Migration guide with before/after examples (547 lines)docs/PATH_RESERVATION_ARCHITECTURE.md- Original reservation service design (1069 lines)
Scope-per-Context Pattern:
Each context (DefaultEditingContext and DefaultSimulationContext) creates its own Koin scope and passes itself as the scope source:
// In DefaultSimulationContext constructor:
val scope = GlobalContext.get().createScope(
scopeId = System.identityHashCode(this).toString(),
qualifier = named<DefaultSimulationContext>(),
source = this // Context accessible via getSource()
)Why Scoped, Not Singleton or Factory?
- singleton: ❌ State bleeding between simulation runs
- factory: ❌ Each get() creates new instance, components don't share state
- scoped: ✅ One registry per context, shared by all components, isolated between contexts
Services Retrieve Context via getSource():
No redundant parametersOf(context) - context is the scope source:
// In InterlockSimModule.kt:
scope<DefaultSimulationContext> {
scoped<TopologyNavigator> {
val context = getSource<DefaultSimulationContext>()
DefaultTopologyNavigator(context)
}
scoped<PathReservationRegistry> { PathReservationRegistry() }
scoped<PathReservationService> {
val context = getSource<DefaultSimulationContext>()
val navigator: TopologyNavigator = get() // Shared within scope
val registry: PathReservationRegistry = get() // Shared within scope
DefaultPathReservationService(navigator, context, registry)
}
}Usage Patterns:
// Production code - services accessed via context API
val context = buildSimulationContext()
val pathService = context.getPathReservationService()
val trainService = context.getTrainNavigationService()
// Both services share the same registry within this context
pathService.reservePath("train1", start, end)
val blocks = trainService.getReservedBlocks("train1") // Sees the same reservation
// Test code - direct scope access when needed
val navigator = context.scope.get<TopologyNavigator>() // No parameters!
// Clean up scope when done (AutoCloseable pattern)
context.close() // IdempotentKey Benefits:
- No redundant
parametersOf(context)- context is the scope source - Shared registry within context, isolated between contexts
- Type-safe service retrieval via
get<T>() - Resource cleanup via
AutoCloseablepattern - Both
EditingContextandSimulationContextsupport
Factory Pattern (Phase 2, 2026-01-14):
DefaultSimulationContext uses dependency injection to obtain a SimulationProcessFactory rather than directly instantiating simulation classes. This:
- Follows Dependency Inversion Principle (depends on abstraction, not concrete classes)
- Enables testing with mock factories
- Prepares for kDisco→DSOL/Kalasim migration
Module Configuration:
val simulationModule = module {
single<SimulationProcessFactory> { DefaultSimulationProcessFactory() }
}Usage in Context:
class DefaultSimulationContext(
cols: Int,
rows: Int,
private val processFactory: SimulationProcessFactory // Injected via Koin
) : BaseContext(cols, rows), SimulationContext {
// Factory creates simulation processes (Generator, InOutWorker)
}Requirement: Every railway network must have at least 1 InOut element (entry/exit point).
Rationale:
- 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 < 1 InOuts (Issue #80)
- XML loading: XMLContextFactory validates during parse and throws
IllegalArgumentException - Test coverage: See
InOutValidationTest(Issue #79)
Example Valid Network (single InOut):
<RailwayNet>
<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>This style guide should be updated as the migration progresses and patterns emerge. After Phase 3 conversion is complete, we may gradually introduce more Kotlin idioms in Phase 4+.
Last Updated: 2026-02-05 (Major update: Added Build & Development Environment, Logging Configuration, Code Quality Enforcement, Code Modification Guidelines, Project Architecture Context sections. Expanded from 1634 to ~2400 lines as part of CLAUDE.md compaction effort.)