diff --git a/main/src/main/java/sd/coordinator/CoordinatorProcess.java b/main/src/main/java/sd/coordinator/CoordinatorProcess.java new file mode 100644 index 0000000..9d25ee0 --- /dev/null +++ b/main/src/main/java/sd/coordinator/CoordinatorProcess.java @@ -0,0 +1,204 @@ +package sd.coordinator; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import sd.config.SimulationConfig; +import sd.model.Message; +import sd.model.MessageType; +import sd.model.Vehicle; +import sd.serialization.SerializationException; +import sd.util.VehicleGenerator; + +/** + * Coordinator process responsible for: + * 1. Vehicle generation (using VehicleGenerator) + * 2. Distributing vehicles to intersection processes via sockets + * 3. Managing simulation timing and shutdown + * + * This is the main entry point for the distributed simulation architecture. + */ +public class CoordinatorProcess { + + private final SimulationConfig config; + private final VehicleGenerator vehicleGenerator; + private final Map intersectionClients; + private double currentTime; + private int vehicleCounter; + private boolean running; + private double nextGenerationTime; + + public static void main(String[] args) { + System.out.println("=".repeat(60)); + System.out.println("COORDINATOR PROCESS - DISTRIBUTED TRAFFIC SIMULATION"); + System.out.println("=".repeat(60)); + + try { + // 1. Load configuration + String configFile = args.length > 0 ? args[0] : "src/main/resources/simulation.properties"; + System.out.println("Loading configuration from: " + configFile); + + SimulationConfig config = new SimulationConfig(configFile); + CoordinatorProcess coordinator = new CoordinatorProcess(config); + + // 2. Connect to intersection processes + System.out.println("\n" + "=".repeat(60)); + coordinator.initialize(); + + // 3. Run the sim + System.out.println("\n" + "=".repeat(60)); + coordinator.run(); + + } catch (IOException e) { + System.err.println("Failed to load configuration: " + e.getMessage()); + System.exit(1); + } catch (Exception e) { + System.err.println("Coordinator error: " + e.getMessage()); + System.exit(1); + } + } + + public CoordinatorProcess(SimulationConfig config) { + this.config = config; + this.vehicleGenerator = new VehicleGenerator(config); + this.intersectionClients = new HashMap<>(); + this.currentTime = 0.0; + this.vehicleCounter = 0; + this.running = false; + this.nextGenerationTime = 0.0; + + System.out.println("Coordinator initialized with configuration:"); + System.out.println(" - Simulation duration: " + config.getSimulationDuration() + "s"); + System.out.println(" - Arrival model: " + config.getArrivalModel()); + System.out.println(" - Arrival rate: " + config.getArrivalRate() + " vehicles/s"); + } + + public void initialize() { + System.out.println("Connecting to intersection processes..."); + + String[] intersectionIds = {"Cr1", "Cr2", "Cr3", "Cr4", "Cr5"}; + + for (String intersectionId : intersectionIds) { + try { + String host = config.getIntersectionHost(intersectionId); + int port = config.getIntersectionPort(intersectionId); + + SocketClient client = new SocketClient(intersectionId, host, port); + client.connect(); + intersectionClients.put(intersectionId, client); + + } catch (IOException e) { + System.err.println("Failed to connect to " + intersectionId + ": " + e.getMessage()); + } + } + + System.out.println("Successfully connected to " + intersectionClients.size() + " intersection(s)"); + + if (intersectionClients.isEmpty()) { + System.err.println("WARNING: No intersections connected. Simulation cannot proceed."); + } + } + + public void run() { + double duration = config.getSimulationDuration(); + running = true; + + System.out.println("Starting vehicle generation simulation..."); + System.out.println("Duration: " + duration + " seconds"); + System.out.println(); + + nextGenerationTime = vehicleGenerator.getNextArrivalTime(currentTime); + final double TIME_STEP = 0.1; + + while (running && currentTime < duration) { + if (currentTime >= nextGenerationTime) { + generateAndSendVehicle(); + nextGenerationTime = vehicleGenerator.getNextArrivalTime(currentTime); + } + currentTime += TIME_STEP; + } + + System.out.println(); + System.out.println("Simulation complete at t=" + String.format("%.2f", currentTime) + "s"); + System.out.println("Total vehicles generated: " + vehicleCounter); + + shutdown(); + } + + private void generateAndSendVehicle() { + Vehicle vehicle = vehicleGenerator.generateVehicle("V" + (++vehicleCounter), currentTime); + + System.out.printf("[t=%.2f] Vehicle %s generated (type=%s, route=%s)%n", + currentTime, vehicle.getId(), vehicle.getType(), vehicle.getRoute()); + + if (vehicle.getRoute().isEmpty()) { + System.err.println("ERROR: Vehicle " + vehicle.getId() + " has empty route!"); + return; + } + + String entryIntersection = vehicle.getRoute().get(0); + sendVehicleToIntersection(vehicle, entryIntersection); + } + + private void sendVehicleToIntersection(Vehicle vehicle, String intersectionId) { + SocketClient client = intersectionClients.get(intersectionId); + + if (client == null || !client.isConnected()) { + System.err.println("ERROR: No connection to " + intersectionId + " for vehicle " + vehicle.getId()); + return; + } + + try { + Message message = new Message( + MessageType.VEHICLE_SPAWN, + "COORDINATOR", + intersectionId, + vehicle + ); + + client.send(message); + System.out.printf("->Sent to %s%n", intersectionId); + + } catch (SerializationException | IOException e) { + System.err.println("ERROR: Failed to send vehicle " + vehicle.getId() + " to " + intersectionId); + System.err.println("Reason: " + e.getMessage()); + } + } + + public void shutdown() { + System.out.println(); + System.out.println("=".repeat(60)); + System.out.println("Shutting down coordinator..."); + + for (Map.Entry entry : intersectionClients.entrySet()) { + String intersectionId = entry.getKey(); + SocketClient client = entry.getValue(); + + try { + if (client.isConnected()) { + Message personalizedShutdown = new Message( + MessageType.SHUTDOWN, + "COORDINATOR", + intersectionId, + "Simulation complete" + ); + client.send(personalizedShutdown); + System.out.println("Sent shutdown message to " + intersectionId); + } + } catch (SerializationException | IOException e) { + System.err.println("Error sending shutdown to " + intersectionId + ": " + e.getMessage()); + } finally { + client.close(); + } + } + + System.out.println("Coordinator shutdown complete"); + System.out.println("=".repeat(60)); + } + + public void stop() { + System.out.println("\nStop signal received..."); + running = false; + } +} diff --git a/main/src/main/java/sd/coordinator/SocketClient.java b/main/src/main/java/sd/coordinator/SocketClient.java new file mode 100644 index 0000000..88d75b2 --- /dev/null +++ b/main/src/main/java/sd/coordinator/SocketClient.java @@ -0,0 +1,124 @@ +package sd.coordinator; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.Socket; + +import sd.model.Message; +import sd.serialization.MessageSerializer; +import sd.serialization.SerializationException; +import sd.serialization.SerializerFactory; + +/** + * Socket client for communication with a single intersection process. + * + * Handles a persistent TCP connection to one intersection, + * providing a simple way to send serialized messages. + */ +public class SocketClient { + + private final String intersectionId; + private final String host; + private final int port; + private Socket socket; + private OutputStream outputStream; + private MessageSerializer serializer; + + /** + * Creates a new SocketClient for a given intersection. + * + * @param intersectionId Intersection ID (ex. "Cr1") + * @param host Host address (ex. "localhost") + * @param port Port number + */ + public SocketClient(String intersectionId, String host, int port) { + this.intersectionId = intersectionId; + this.host = host; + this.port = port; + this.serializer = SerializerFactory.createDefault(); + } + + /** + * Connects to the intersection process via TCP. + * + * @throws IOException if the connection cannot be established + */ + + public void connect() throws IOException { + try { + socket = new Socket(host, port); + outputStream = socket.getOutputStream(); + System.out.println("Connected to " + intersectionId + " at " + host + ":" + port); + } catch (IOException e) { + System.err.println("Failed to connect to " + intersectionId + " at " + host + ":" + port); + throw e; + } + } + + /** + * Sends a message to the connected intersection. + * The message is serialized and written over the socket. + * + * @param message The message to send + * @throws SerializationException if serialization fails + * @throws IOException if the socket write fails + */ + public void send(Message message) throws SerializationException, IOException { + if (socket == null || socket.isClosed()) { + throw new IOException("Socket is not connected to " + intersectionId); + } + + try { + byte[] data = serializer.serialize(message); + + // Prefix with message length (so receiver knows how much to read) + int length = data.length; + outputStream.write((length >> 24) & 0xFF); + outputStream.write((length >> 16) & 0xFF); + outputStream.write((length >> 8) & 0xFF); + outputStream.write(length & 0xFF); + + outputStream.write(data); + outputStream.flush(); + + } catch (SerializationException | IOException e) { + System.err.println("Error sending message to " + intersectionId + ": " + e.getMessage()); + throw e; + } + } + + /** + * Closes the socket connection safely. + * Calling it multiple times won’t cause issues. + */ + public void close() { + try { + if (outputStream != null) { + outputStream.close(); + } + if (socket != null && !socket.isClosed()) { + socket.close(); + System.out.println("Closed connection to " + intersectionId); + } + } catch (IOException e) { + System.err.println("Error closing connection to " + intersectionId + ": " + e.getMessage()); + } + } + + /** + * @return true if connected and socket is open, false otherwise + */ + public boolean isConnected() { + return socket != null && socket.isConnected() && !socket.isClosed(); + } + + public String getIntersectionId() { + return intersectionId; + } + + @Override + public String toString() { + return String.format("SocketClient[intersection=%s, host=%s, port=%d, connected=%s]", + intersectionId, host, port, isConnected()); + } +} diff --git a/main/src/main/java/sd/model/Intersection.java b/main/src/main/java/sd/model/Intersection.java index 4475fc3..bc8dea7 100644 --- a/main/src/main/java/sd/model/Intersection.java +++ b/main/src/main/java/sd/model/Intersection.java @@ -114,8 +114,8 @@ public class Intersection { public void receiveVehicle(Vehicle vehicle) { totalVehiclesReceived++; - // Advance route since vehicle just arrived at this intersection - vehicle.advanceRoute(); + // Note: Route advancement is handled by SimulationEngine.handleVehicleArrival() + // before calling this method, so we don't advance here. String nextDestination = vehicle.getCurrentDestination(); diff --git a/main/src/test/java/sd/coordinator/CoordinatorIntegrationTest.java b/main/src/test/java/sd/coordinator/CoordinatorIntegrationTest.java new file mode 100644 index 0000000..7264f87 --- /dev/null +++ b/main/src/test/java/sd/coordinator/CoordinatorIntegrationTest.java @@ -0,0 +1,302 @@ +package sd.coordinator; + +import java.io.DataInputStream; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import sd.model.Message; +import sd.model.MessageType; +import sd.model.Vehicle; +import sd.serialization.MessageSerializer; +import sd.serialization.SerializerFactory; + +/** + * Integration tests for the Coordinator-side networking. + * + * What we’re checking here: + * 1. A SocketClient can actually connect to something listening + * 2. Messages go over the wire and can be deserialized + * 3. Vehicle payloads survive the trip + * 4. Shutdown messages can be broadcast to multiple intersections + * + * We do this by spinning up a tiny mock intersection server in-process. + */ +class CoordinatorIntegrationTest { + + private List mockServers; + private static final int BASE_PORT = 9001; // keep clear of real ports + + @BeforeEach + void setUp() { + mockServers = new ArrayList<>(); + } + + @AfterEach + void tearDown() { + // Stop all mock servers + for (MockIntersectionServer server : mockServers) { + server.stop(); + } + mockServers.clear(); + } + + /** + * Can the client open a TCP connection to our fake intersection? + */ + @Test + @Timeout(5) + void testSocketClientConnection() throws IOException, InterruptedException { + MockIntersectionServer server = new MockIntersectionServer("Cr1", BASE_PORT); + server.start(); + mockServers.add(server); + + // tiny pause to let the server bind + Thread.sleep(100); + + SocketClient client = new SocketClient("Cr1", "localhost", BASE_PORT); + client.connect(); + + assertTrue(client.isConnected(), "Client should be connected to mock intersection"); + + client.close(); + } + + /** + * End-to-end: send a message, make sure the server actually receives it. + */ + @Test + @Timeout(5) + void testMessageTransmission() throws Exception { + MockIntersectionServer server = new MockIntersectionServer("Cr1", BASE_PORT); + server.start(); + mockServers.add(server); + + Thread.sleep(100); + + SocketClient client = new SocketClient("Cr1", "localhost", BASE_PORT); + client.connect(); + + Message testMessage = new Message( + MessageType.VEHICLE_SPAWN, + "COORDINATOR", + "Cr1", + "Test payload" + ); + + client.send(testMessage); + + // give the server a moment to read and deserialize + Thread.sleep(200); + + assertFalse( + server.getReceivedMessages().isEmpty(), + "Mock server should have received at least one message" + ); + + Message receivedMsg = server.getReceivedMessages().poll(); + assertNotNull(receivedMsg, "Server should have actually received a message"); + assertEquals(MessageType.VEHICLE_SPAWN, receivedMsg.getType(), "Message type should match what we sent"); + assertEquals("COORDINATOR", receivedMsg.getSenderId(), "Sender ID should be preserved"); + assertEquals("Cr1", receivedMsg.getDestinationId(), "Destination ID should be preserved"); + + client.close(); + } + + /** + * Make sure vehicle payloads survive the trip and arrive non-null. + */ + @Test + @Timeout(5) + void testVehicleSpawnMessage() throws Exception { + MockIntersectionServer server = new MockIntersectionServer("Cr1", BASE_PORT); + server.start(); + mockServers.add(server); + + Thread.sleep(100); + + SocketClient client = new SocketClient("Cr1", "localhost", BASE_PORT); + client.connect(); + + // fake a vehicle like the coordinator would send + List route = List.of("Cr1", "Cr4", "Cr5", "S"); + Vehicle vehicle = new Vehicle("V1", sd.model.VehicleType.LIGHT, 0.0, route); + + Message spawnMessage = new Message( + MessageType.VEHICLE_SPAWN, + "COORDINATOR", + "Cr1", + vehicle + ); + + client.send(spawnMessage); + + Thread.sleep(200); + + Message receivedMsg = server.getReceivedMessages().poll(); + assertNotNull(receivedMsg, "Mock server should receive the spawn message"); + assertEquals(MessageType.VEHICLE_SPAWN, receivedMsg.getType(), "Message should be of type VEHICLE_SPAWN"); + assertNotNull(receivedMsg.getPayload(), "Payload should not be null (vehicle must arrive)"); + + client.close(); + } + + /** + * Broadcast shutdown to multiple mock intersections and see if all of them get it. + */ + @Test + @Timeout(5) + void testShutdownMessageBroadcast() throws Exception { + // Start a couple of fake intersections + for (int i = 1; i <= 3; i++) { + MockIntersectionServer server = new MockIntersectionServer("Cr" + i, BASE_PORT + i - 1); + server.start(); + mockServers.add(server); + } + + Thread.sleep(200); + + // Connect to all of them + List clients = new ArrayList<>(); + for (int i = 1; i <= 3; i++) { + SocketClient client = new SocketClient("Cr" + i, "localhost", BASE_PORT + i - 1); + client.connect(); + clients.add(client); + } + + Message shutdownMessage = new Message( + MessageType.SHUTDOWN, + "COORDINATOR", + "ALL", + "Simulation complete" + ); + + for (SocketClient client : clients) { + client.send(shutdownMessage); + } + + Thread.sleep(200); + + for (MockIntersectionServer server : mockServers) { + assertFalse( + server.getReceivedMessages().isEmpty(), + "Server " + server.getIntersectionId() + " should have received the shutdown message" + ); + + Message msg = server.getReceivedMessages().poll(); + assertEquals(MessageType.SHUTDOWN, msg.getType(), "Server should receive a SHUTDOWN message"); + } + + for (SocketClient client : clients) { + client.close(); + } + } + + /** + * Tiny TCP server that pretends to be an intersection. + * It: + * - listens on a port + * - accepts connections + * - reads length-prefixed messages + * - deserializes them and stores them for the test to inspect + */ + private static class MockIntersectionServer { + private final String intersectionId; + private final int port; + private ServerSocket serverSocket; + private Thread serverThread; + private volatile boolean running; + private final ConcurrentLinkedQueue receivedMessages; + private final MessageSerializer serializer; + + public MockIntersectionServer(String intersectionId, int port) { + this.intersectionId = intersectionId; + this.port = port; + this.receivedMessages = new ConcurrentLinkedQueue<>(); + this.serializer = SerializerFactory.createDefault(); + this.running = false; + } + + public void start() throws IOException { + serverSocket = new ServerSocket(port); + running = true; + + System.out.printf("Mock %s listening on port %d%n", intersectionId, port); + + serverThread = new Thread(() -> { + try { + while (running) { + Socket clientSocket = serverSocket.accept(); + handleClient(clientSocket); + } + } catch (IOException e) { + if (running) { + System.err.println("Mock " + intersectionId + " server error: " + e.getMessage()); + } + } + }, "mock-" + intersectionId + "-listener"); + + serverThread.start(); + } + + private void handleClient(Socket clientSocket) { + new Thread(() -> { + try (DataInputStream input = new DataInputStream(clientSocket.getInputStream())) { + while (running) { + // Read length prefix (4 bytes, big-endian) + int length = input.readInt(); + byte[] data = new byte[length]; + input.readFully(data); + + Message message = serializer.deserialize(data, Message.class); + receivedMessages.offer(message); + + System.out.println("Mock " + intersectionId + " received: " + message.getType()); + } + } catch (IOException e) { + if (running) { + System.err.println("Mock " + intersectionId + " client handler error: " + e.getMessage()); + } + } catch (Exception e) { + System.err.println("Mock " + intersectionId + " deserialization error: " + e.getMessage()); + } + }, "mock-" + intersectionId + "-client").start(); + } + + public void stop() { + running = false; + try { + if (serverSocket != null && !serverSocket.isClosed()) { + serverSocket.close(); + } + if (serverThread != null) { + serverThread.interrupt(); + serverThread.join(1000); + } + System.out.printf("Mock %s stopped%n", intersectionId); + } catch (IOException | InterruptedException e) { + System.err.println("Error stopping mock server " + intersectionId + ": " + e.getMessage()); + } + } + + public ConcurrentLinkedQueue getReceivedMessages() { + return receivedMessages; + } + + public String getIntersectionId() { + return intersectionId; + } + } +} diff --git a/main/src/test/java/sd/coordinator/CoordinatorProcessTest.java b/main/src/test/java/sd/coordinator/CoordinatorProcessTest.java new file mode 100644 index 0000000..f334d90 --- /dev/null +++ b/main/src/test/java/sd/coordinator/CoordinatorProcessTest.java @@ -0,0 +1,194 @@ +package sd.coordinator; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import sd.config.SimulationConfig; +import sd.model.Vehicle; +import sd.util.VehicleGenerator; + +/** + * Tests for the Coordinator/vehicle-generation layer. + * + * What we’re checking here: + * 1. Coordinator can be created with a valid config + * 2. Vehicle arrival times are monotonic and sane + * 3. Vehicle IDs are created in the format we expect (V1, V2, ...) + * 4. Generated vehicles have proper routes (start at CrX, end at S) + * 5. Config actually has intersection info + * 6. Duration in config is not something crazy + */ +class CoordinatorProcessTest { + + private SimulationConfig config; + private static final String TEST_CONFIG = "src/main/resources/simulation.properties"; + + @BeforeEach + void setUp() throws IOException { + config = new SimulationConfig(TEST_CONFIG); + } + + @AfterEach + void tearDown() { + config = null; + } + + /** + * Basic smoke test: can we build a coordinator with this config? + */ + @Test + void testCoordinatorInitialization() { + CoordinatorProcess coordinator = new CoordinatorProcess(config); + assertNotNull(coordinator, "Coordinator should be created with a valid config"); + } + + /** + * Make sure the VehicleGenerator is giving us increasing arrival times, + * i.e. time doesn’t go backwards and intervals look reasonable. + */ + @Test + void testVehicleGenerationTiming() { + VehicleGenerator generator = new VehicleGenerator(config); + + double currentTime = 0.0; + List arrivalTimes = new ArrayList<>(); + + // generate a small batch to inspect + for (int i = 0; i < 10; i++) { + double nextArrival = generator.getNextArrivalTime(currentTime); + arrivalTimes.add(nextArrival); + currentTime = nextArrival; + } + + // times should strictly increase + for (int i = 1; i < arrivalTimes.size(); i++) { + assertTrue( + arrivalTimes.get(i) > arrivalTimes.get(i - 1), + "Arrival times must increase — got " + arrivalTimes.get(i - 1) + " then " + arrivalTimes.get(i) + ); + } + + // and they shouldn't be nonsense + for (double time : arrivalTimes) { + assertTrue(time >= 0, "Arrival time should not be negative (got " + time + ")"); + assertTrue(time < 1000, "Arrival time looks suspiciously large: " + time); + } + } + + /** + * We generate V1..V5 manually and make sure the IDs are exactly those. + */ + @Test + void testVehicleIdGeneration() { + VehicleGenerator generator = new VehicleGenerator(config); + + List vehicles = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + Vehicle v = generator.generateVehicle("V" + i, 0.0); + vehicles.add(v); + assertEquals("V" + i, v.getId(), "Vehicle ID should be 'V" + i + "' but got " + v.getId()); + } + + // just to be safe, no duplicates in that small set + long distinctCount = vehicles.stream().map(Vehicle::getId).distinct().count(); + assertEquals(5, distinctCount, "Vehicle IDs in this batch should all be unique"); + } + + /** + * A generated vehicle should: + * - have a non-empty route + * - start in a known intersection (Cr1..Cr5) + * - end in S (exit) + */ + @Test + void testVehicleRouteValidity() { + VehicleGenerator generator = new VehicleGenerator(config); + + for (int i = 0; i < 20; i++) { + Vehicle vehicle = generator.generateVehicle("V" + i, 0.0); + + assertNotNull(vehicle.getRoute(), "Vehicle route should not be null"); + assertFalse(vehicle.getRoute().isEmpty(), "Vehicle route should not be empty"); + + String firstHop = vehicle.getRoute().get(0); + assertTrue( + firstHop.matches("Cr[1-5]"), + "First hop should be a valid intersection (Cr1..Cr5), got: " + firstHop + ); + + String lastHop = vehicle.getRoute().get(vehicle.getRoute().size() - 1); + assertEquals("S", lastHop, "Last hop should be exit 'S' but got: " + lastHop); + } + } + + /** + * Whatever is in simulation.properties should give us a sane duration. + */ + @Test + void testSimulationDuration() { + double duration = config.getSimulationDuration(); + assertTrue(duration > 0, "Simulation duration must be positive"); + assertTrue(duration >= 1.0, "Simulation should run at least 1 second (got " + duration + ")"); + assertTrue(duration <= 86400.0, "Simulation should not run more than a day (got " + duration + ")"); + } + + /** + * Check that the 5 intersections defined in the architecture + * actually exist in the config and have valid network data. + */ + @Test + void testIntersectionConfiguration() { + String[] intersectionIds = {"Cr1", "Cr2", "Cr3", "Cr4", "Cr5"}; + + for (String id : intersectionIds) { + String host = config.getIntersectionHost(id); + int port = config.getIntersectionPort(id); + + assertNotNull(host, "Host should not be null for " + id); + assertFalse(host.isEmpty(), "Host should not be empty for " + id); + assertTrue(port > 0, "Port should be > 0 for " + id + " (got " + port + ")"); + assertTrue(port < 65536, "Port should be a valid TCP port for " + id + " (got " + port + ")"); + } + } + + /** + * Quick sanity check: over a bunch of generated vehicles, + * we should eventually see the different vehicle types appear. + * + * Note: this is probabilistic, so we're not being super strict. + */ + @Test + void testVehicleTypeDistribution() { + VehicleGenerator generator = new VehicleGenerator(config); + + boolean hasBike = false; + boolean hasLight = false; + boolean hasHeavy = false; + + // 50 is enough for a "we're probably fine" test + for (int i = 0; i < 50; i++) { + Vehicle vehicle = generator.generateVehicle("V" + i, 0.0); + + switch (vehicle.getType()) { + case BIKE -> hasBike = true; + case LIGHT -> hasLight = true; + case HEAVY -> hasHeavy = true; + } + } + + // at least one of them should have shown up — if not, RNG is cursed + assertTrue( + hasBike || hasLight || hasHeavy, + "Expected to see at least one vehicle type after 50 generations" + ); + } +}