feat: simulate PLC vehicle archive and WMS behavior
This commit is contained in:
@@ -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<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcFailures;
|
||||||
|
private final Map<Long, List<VehicleEvent>> vehicleEvents;
|
||||||
|
|
||||||
|
private DeterministicFaultPlan(
|
||||||
|
Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcFailures,
|
||||||
|
Map<Long, List<VehicleEvent>> 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<Integer, SimulatedPlcGateway.PlcReason> byAttempt = plcFailures.get(taskId);
|
||||||
|
return byAttempt == null ? null : byAttempt.get(attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<VehicleEvent> eventsAt(long eventNumber) {
|
||||||
|
List<VehicleEvent> events = vehicleEvents.get(eventNumber);
|
||||||
|
return events == null ? Collections.emptyList() : events;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class Builder {
|
||||||
|
private final Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcFailures =
|
||||||
|
new LinkedHashMap<String, Map<Integer, SimulatedPlcGateway.PlcReason>>();
|
||||||
|
private final Map<Long, List<VehicleEvent>> vehicleEvents =
|
||||||
|
new LinkedHashMap<Long, List<VehicleEvent>>();
|
||||||
|
|
||||||
|
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<Integer, SimulatedPlcGateway.PlcReason> byAttempt = plcFailures.get(taskId);
|
||||||
|
if (byAttempt == null) {
|
||||||
|
byAttempt = new LinkedHashMap<Integer, SimulatedPlcGateway.PlcReason>();
|
||||||
|
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<VehicleEvent> events = vehicleEvents.get(eventNumber);
|
||||||
|
if (events == null) {
|
||||||
|
events = new ArrayList<VehicleEvent>();
|
||||||
|
vehicleEvents.put(eventNumber, events);
|
||||||
|
}
|
||||||
|
events.add(new VehicleEvent(vehicleId, type));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeterministicFaultPlan build() {
|
||||||
|
Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plc =
|
||||||
|
new LinkedHashMap<String, Map<Integer, SimulatedPlcGateway.PlcReason>>();
|
||||||
|
for (Map.Entry<String, Map<Integer, SimulatedPlcGateway.PlcReason>> entry
|
||||||
|
: plcFailures.entrySet()) {
|
||||||
|
plc.put(entry.getKey(), Collections.unmodifiableMap(
|
||||||
|
new LinkedHashMap<Integer, SimulatedPlcGateway.PlcReason>(entry.getValue())));
|
||||||
|
}
|
||||||
|
Map<Long, List<VehicleEvent>> vehicles =
|
||||||
|
new LinkedHashMap<Long, List<VehicleEvent>>();
|
||||||
|
for (Map.Entry<Long, List<VehicleEvent>> entry : vehicleEvents.entrySet()) {
|
||||||
|
vehicles.put(entry.getKey(), Collections.unmodifiableList(
|
||||||
|
new ArrayList<VehicleEvent>(entry.getValue())));
|
||||||
|
}
|
||||||
|
return new DeterministicFaultPlan(Collections.unmodifiableMap(plc),
|
||||||
|
Collections.unmodifiableMap(vehicles));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> completedTaskIds = new LinkedHashSet<String>();
|
||||||
|
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<String> getCompletedTaskIds() {
|
||||||
|
return Collections.unmodifiableSet(completedTaskIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Integer> attempts = new LinkedHashMap<String, Integer>();
|
||||||
|
|
||||||
|
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<String, Integer> getAttempts() {
|
||||||
|
return Collections.unmodifiableMap(attempts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<String, ArchiveRecord> records =
|
||||||
|
new LinkedHashMap<String, ArchiveRecord>();
|
||||||
|
|
||||||
|
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<String, ArchiveRecord> getRecords() {
|
||||||
|
return Collections.unmodifiableMap(records);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user