diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/DeterministicFaultPlan.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/DeterministicFaultPlan.java new file mode 100644 index 0000000..cd5e042 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/DeterministicFaultPlan.java @@ -0,0 +1,105 @@ +package com.ft546.wcs.shadow.history.simulation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class DeterministicFaultPlan { + public enum VehicleEventType { + DISCONNECT, CONNECT, RESTART, SYNCHRONIZE, + LOW_BATTERY, RESTORE_BATTERY, FAULT, RECOVER + } + + public static final class VehicleEvent { + private final String vehicleId; + private final VehicleEventType type; + + private VehicleEvent(String vehicleId, VehicleEventType type) { + this.vehicleId = vehicleId; + this.type = type; + } + + public String getVehicleId() { return vehicleId; } + public VehicleEventType getType() { return type; } + } + + private final Map> plcFailures; + private final Map> vehicleEvents; + + private DeterministicFaultPlan( + Map> plcFailures, + Map> vehicleEvents) { + this.plcFailures = plcFailures; + this.vehicleEvents = vehicleEvents; + } + + public static Builder builder() { return new Builder(); } + + public static DeterministicFaultPlan none() { return builder().build(); } + + SimulatedPlcGateway.PlcReason plcReason(String taskId, int attempt) { + Map byAttempt = plcFailures.get(taskId); + return byAttempt == null ? null : byAttempt.get(attempt); + } + + public List eventsAt(long eventNumber) { + List events = vehicleEvents.get(eventNumber); + return events == null ? Collections.emptyList() : events; + } + + public static final class Builder { + private final Map> plcFailures = + new LinkedHashMap>(); + private final Map> vehicleEvents = + new LinkedHashMap>(); + + public Builder plcFailure(String taskId, int attempt, + SimulatedPlcGateway.PlcReason reason) { + if (taskId == null || attempt <= 0 || reason == null + || reason == SimulatedPlcGateway.PlcReason.ACK + || reason == SimulatedPlcGateway.PlcReason.STALE_BOOT_ID) { + throw new IllegalArgumentException("valid PLC failure is required"); + } + Map byAttempt = plcFailures.get(taskId); + if (byAttempt == null) { + byAttempt = new LinkedHashMap(); + plcFailures.put(taskId, byAttempt); + } + byAttempt.put(attempt, reason); + return this; + } + + public Builder vehicleEvent(long eventNumber, String vehicleId, VehicleEventType type) { + if (eventNumber < 0 || vehicleId == null || vehicleId.trim().isEmpty() || type == null) { + throw new IllegalArgumentException("valid vehicle event is required"); + } + List events = vehicleEvents.get(eventNumber); + if (events == null) { + events = new ArrayList(); + vehicleEvents.put(eventNumber, events); + } + events.add(new VehicleEvent(vehicleId, type)); + return this; + } + + public DeterministicFaultPlan build() { + Map> plc = + new LinkedHashMap>(); + for (Map.Entry> entry + : plcFailures.entrySet()) { + plc.put(entry.getKey(), Collections.unmodifiableMap( + new LinkedHashMap(entry.getValue()))); + } + Map> vehicles = + new LinkedHashMap>(); + for (Map.Entry> entry : vehicleEvents.entrySet()) { + vehicles.put(entry.getKey(), Collections.unmodifiableList( + new ArrayList(entry.getValue()))); + } + return new DeterministicFaultPlan(Collections.unmodifiableMap(plc), + Collections.unmodifiableMap(vehicles)); + } + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/IdempotentWmsCompletionReceiver.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/IdempotentWmsCompletionReceiver.java new file mode 100644 index 0000000..8442291 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/IdempotentWmsCompletionReceiver.java @@ -0,0 +1,24 @@ +package com.ft546.wcs.shadow.history.simulation; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +public final class IdempotentWmsCompletionReceiver { + private final Set completedTaskIds = new LinkedHashSet(); + private int deliveryAttempts; + + public boolean receive(String taskId) { + if (taskId == null || taskId.trim().isEmpty()) { + throw new IllegalArgumentException("task id is required"); + } + deliveryAttempts++; + return completedTaskIds.add(taskId); + } + + public int getDeliveryAttempts() { return deliveryAttempts; } + public int getBusinessEffects() { return completedTaskIds.size(); } + public Set getCompletedTaskIds() { + return Collections.unmodifiableSet(completedTaskIds); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulatedPlcGateway.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulatedPlcGateway.java new file mode 100644 index 0000000..77cf8f4 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulatedPlcGateway.java @@ -0,0 +1,77 @@ +package com.ft546.wcs.shadow.history.simulation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class SimulatedPlcGateway { + public enum PlcReason { + ACK, SEND_FAILURE, ACK_TIMEOUT, ACK_LOST, STALE_BOOT_ID + } + + public static final class PlcResponse { + private final String taskId; + private final String vehicleId; + private final String from; + private final String to; + private final int attempt; + private final long bootId; + private final PlcReason reason; + + private PlcResponse(String taskId, String vehicleId, String from, String to, + int attempt, long bootId, PlcReason reason) { + this.taskId = taskId; + this.vehicleId = vehicleId; + this.from = from; + this.to = to; + this.attempt = attempt; + this.bootId = bootId; + this.reason = reason; + } + + public String getTaskId() { return taskId; } + public String getVehicleId() { return vehicleId; } + public String getFrom() { return from; } + public String getTo() { return to; } + public int getAttempt() { return attempt; } + public long getBootId() { return bootId; } + public PlcReason getReason() { return reason; } + } + + private final DeterministicFaultPlan faults; + private final Map attempts = new LinkedHashMap(); + + public SimulatedPlcGateway(DeterministicFaultPlan faults) { + if (faults == null) throw new IllegalArgumentException("fault plan is required"); + this.faults = faults; + } + + public PlcResponse send(String taskId, SimulatedVehicleRuntime vehicle, + String from, String to) { + int attempt = attempts.containsKey(taskId) ? attempts.get(taskId) + 1 : 1; + attempts.put(taskId, attempt); + PlcReason reason = faults.plcReason(taskId, attempt); + if (reason == null) reason = PlcReason.ACK; + return new PlcResponse(taskId, vehicle.getVehicleId(), from, to, + attempt, vehicle.getBootId(), reason); + } + + public PlcResponse accept(PlcResponse response, SimulatedVehicleRuntime vehicle) { + if (response.getReason() != PlcReason.ACK) return response; + if (response.getBootId() != vehicle.getBootId()) { + return new PlcResponse(response.getTaskId(), response.getVehicleId(), + response.getFrom(), response.getTo(), response.getAttempt(), + response.getBootId(), PlcReason.STALE_BOOT_ID); + } + return response; + } + + public int attemptsFor(String taskId) { + Integer value = attempts.get(taskId); + return value == null ? 0 : value; + } + + public Map getAttempts() { + return Collections.unmodifiableMap(attempts); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulatedVehicleRuntime.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulatedVehicleRuntime.java new file mode 100644 index 0000000..c735e4e --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulatedVehicleRuntime.java @@ -0,0 +1,78 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.ft546.wcs.demo.model.VehicleSnapshot; + +public final class SimulatedVehicleRuntime { + private final String vehicleId; + private String currentSite; + private int floor; + private int battery = 100; + private boolean connected = true; + private boolean faulted; + private boolean synchronizedAfterBoot = true; + private long bootId = 1L; + + public SimulatedVehicleRuntime(String vehicleId, String currentSite, int floor) { + if (vehicleId == null || vehicleId.trim().isEmpty() + || currentSite == null || currentSite.trim().isEmpty() || floor <= 0) { + throw new IllegalArgumentException("valid vehicle identity and position are required"); + } + this.vehicleId = vehicleId; + this.currentSite = currentSite; + this.floor = floor; + } + + public static SimulatedVehicleRuntime from(VehicleSnapshot snapshot) { + SimulatedVehicleRuntime runtime = new SimulatedVehicleRuntime( + snapshot.getVehicleId(), snapshot.getCurrentSite(), snapshot.getFloor()); + runtime.battery = snapshot.getBattery(); + runtime.connected = snapshot.isConnected(); + return runtime; + } + + public boolean isAssignmentEligible() { + return connected && !faulted && synchronizedAfterBoot && battery > 30; + } + + public void disconnect() { connected = false; } + public void connect() { connected = true; } + public void fail() { faulted = true; } + public void recover() { faulted = false; } + + public void restart() { + bootId++; + synchronizedAfterBoot = false; + } + + public void synchronizeAfterRestart() { synchronizedAfterBoot = true; } + + public void setBattery(int battery) { + if (battery < 0 || battery > 100) { + throw new IllegalArgumentException("battery must be between 0 and 100"); + } + this.battery = battery; + } + + public void moveTo(String site, int newFloor) { + if (site == null || site.trim().isEmpty() || newFloor <= 0) { + throw new IllegalArgumentException("valid destination is required"); + } + currentSite = site; + floor = newFloor; + } + + public VehicleSnapshot snapshot() { + return new VehicleSnapshot(vehicleId, floor, currentSite, 5, + faulted ? 0 : 1, battery, connected, + connected && !faulted && synchronizedAfterBoot, false); + } + + public String getVehicleId() { return vehicleId; } + public String getCurrentSite() { return currentSite; } + public int getFloor() { return floor; } + public int getBattery() { return battery; } + public long getBootId() { return bootId; } + public boolean isConnected() { return connected; } + public boolean isFaulted() { return faulted; } + public boolean isSynchronizedAfterBoot() { return synchronizedAfterBoot; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationArchive.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationArchive.java new file mode 100644 index 0000000..fb9637a --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationArchive.java @@ -0,0 +1,37 @@ +package com.ft546.wcs.shadow.history.simulation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class SimulationArchive { + public static final class ArchiveRecord { + private final String taskId; + private final String vehicleId; + private final long completedAtMillis; + + private ArchiveRecord(String taskId, String vehicleId, long completedAtMillis) { + this.taskId = taskId; + this.vehicleId = vehicleId; + this.completedAtMillis = completedAtMillis; + } + + public String getTaskId() { return taskId; } + public String getVehicleId() { return vehicleId; } + public long getCompletedAtMillis() { return completedAtMillis; } + } + + private final Map records = + new LinkedHashMap(); + + public boolean complete(String taskId, String vehicleId, long completedAtMillis) { + if (records.containsKey(taskId)) return false; + records.put(taskId, new ArchiveRecord(taskId, vehicleId, completedAtMillis)); + return true; + } + + public int size() { return records.size(); } + public Map getRecords() { + return Collections.unmodifiableMap(records); + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulationAdaptersTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulationAdaptersTest.java new file mode 100644 index 0000000..09b14e4 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulationAdaptersTest.java @@ -0,0 +1,61 @@ +package com.ft546.wcs.shadow.history.simulation; + +import org.junit.jupiter.api.Test; + +import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.ACK; +import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.SEND_FAILURE; +import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.STALE_BOOT_ID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SimulationAdaptersTest { + @Test + void retriesDeterministicSendFailureAndRejectsAckFromAnOldBoot() { + DeterministicFaultPlan faults = DeterministicFaultPlan.builder() + .plcFailure("T1", 1, SEND_FAILURE).build(); + SimulatedPlcGateway plc = new SimulatedPlcGateway(faults); + SimulatedVehicleRuntime vehicle = new SimulatedVehicleRuntime("1", "A", 1); + + assertEquals(SEND_FAILURE, plc.send("T1", vehicle, "A", "B").getReason()); + SimulatedPlcGateway.PlcResponse ack = plc.send("T1", vehicle, "A", "B"); + assertEquals(ACK, ack.getReason()); + assertEquals(ACK, plc.accept(ack, vehicle).getReason()); + + vehicle.restart(); + assertEquals(STALE_BOOT_ID, plc.accept(ack, vehicle).getReason()); + vehicle.synchronizeAfterRestart(); + assertTrue(vehicle.isAssignmentEligible()); + } + + @Test + void excludesDisconnectedLowBatteryAndFaultedVehiclesUntilRecovery() { + SimulatedVehicleRuntime vehicle = new SimulatedVehicleRuntime("1", "A", 1); + assertTrue(vehicle.isAssignmentEligible()); + vehicle.disconnect(); + assertFalse(vehicle.isAssignmentEligible()); + vehicle.connect(); + vehicle.setBattery(29); + assertFalse(vehicle.isAssignmentEligible()); + vehicle.setBattery(100); + vehicle.fail(); + assertFalse(vehicle.isAssignmentEligible()); + vehicle.recover(); + assertTrue(vehicle.isAssignmentEligible()); + } + + @Test + void archivesAndAppliesWmsCompletionExactlyOnceAcrossDuplicateDelivery() { + SimulationArchive archive = new SimulationArchive(); + IdempotentWmsCompletionReceiver wms = new IdempotentWmsCompletionReceiver(); + + assertTrue(archive.complete("T1", "1", 123L)); + assertFalse(archive.complete("T1", "1", 123L)); + assertTrue(wms.receive("T1")); + assertFalse(wms.receive("T1")); + + assertEquals(1, archive.size()); + assertEquals(2, wms.getDeliveryAttempts()); + assertEquals(1, wms.getBusinessEffects()); + } +}