feat: simulate PLC vehicle archive and WMS behavior

This commit is contained in:
zgw
2026-07-31 11:25:00 +08:00
parent 892ea3fce6
commit aa71d6e67b
6 changed files with 382 additions and 0 deletions

View File

@@ -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));
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}