feat(javaTest): add point-to-point simulation service
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
package com.ft546.wcs.shadow.history.simulation;
|
||||||
|
|
||||||
|
import com.ft546.wcs.demo.model.DispatchTask;
|
||||||
|
import com.ft546.wcs.shadow.history.HistoricalQueueRecord;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将审计台的一次 A 到 B 请求转换为仿真器可执行的单任务输入。
|
||||||
|
*
|
||||||
|
* <p>该工厂位于 simulation 包中,只负责构建领域输入,不执行选车或路径规划。
|
||||||
|
*/
|
||||||
|
public final class PointToPointSimulationInputFactory {
|
||||||
|
public static final String TASK_ID = "VISUALIZER-TASK-1";
|
||||||
|
private static final LocalDateTime CREATED_AT = LocalDateTime.of(2026, 8, 5, 0, 0);
|
||||||
|
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于权威地图和初始车队创建唯一的审计任务。
|
||||||
|
*/
|
||||||
|
public HistoricalSimulationInput create(
|
||||||
|
HistoricalSimulationInput base, String sourceSite, String targetSite,
|
||||||
|
DispatchTask.TaskType taskType, int priority, String historicalVehicleId,
|
||||||
|
List<String> historicalPath) {
|
||||||
|
if (base == null || taskType == null) {
|
||||||
|
throw new IllegalArgumentException("BAD_REQUEST: base input and task type are required");
|
||||||
|
}
|
||||||
|
if (base.getGridMap().getSite(sourceSite) == null) {
|
||||||
|
throw new IllegalArgumentException("UNKNOWN_SITE: source " + sourceSite);
|
||||||
|
}
|
||||||
|
if (base.getGridMap().getSite(targetSite) == null) {
|
||||||
|
throw new IllegalArgumentException("UNKNOWN_SITE: target " + targetSite);
|
||||||
|
}
|
||||||
|
if (sourceSite.equals(targetSite)) {
|
||||||
|
throw new IllegalArgumentException("SAME_ENDPOINT: source and target must differ");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> history = historicalPath == null
|
||||||
|
? Collections.emptyList() : historicalPath;
|
||||||
|
String pathInfo = String.join(",", history);
|
||||||
|
HistoricalQueueRecord record = new HistoricalQueueRecord(
|
||||||
|
TASK_ID, TASK_ID, taskTypeCode(taskType), priority,
|
||||||
|
sourceSite, targetSite, pathInfo,
|
||||||
|
TIME.format(CREATED_AT), TIME.format(CREATED_AT.plusMinutes(1)),
|
||||||
|
historicalVehicleId);
|
||||||
|
HistoricalSimulationTask task = new HistoricalSimulationTask(
|
||||||
|
TASK_ID, record, taskType, priority, CREATED_AT,
|
||||||
|
CREATED_AT.plusMinutes(1), sourceSite, targetSite, history, 0);
|
||||||
|
return new HistoricalSimulationInput(
|
||||||
|
Collections.singletonList(task), Collections.emptyList(),
|
||||||
|
base.getGridMap(), base.getTrafficMap(), base.getInitialVehicles());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String taskTypeCode(DispatchTask.TaskType taskType) {
|
||||||
|
switch (taskType) {
|
||||||
|
case INBOUND: return "1";
|
||||||
|
case OUTBOUND: return "2";
|
||||||
|
case RELOCATION: return "3";
|
||||||
|
default: throw new IllegalArgumentException("INVALID_TASK_TYPE: " + taskType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.ft546.wcs.shadow.history.visualizer;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.ft546.wcs.demo.model.DispatchTask;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 描述审计台发起的一次 A 到 B 单任务仿真请求。
|
||||||
|
*
|
||||||
|
* <p>请求中的历史车辆和历史路径只用于结果对照;调度中心仍会基于当前车辆快照重新选车,
|
||||||
|
* 并在完整地图上重新规划路径。
|
||||||
|
*/
|
||||||
|
public final class PointToPointSimulationRequest {
|
||||||
|
/** 审计台支持的确定性故障场景。 */
|
||||||
|
public enum FaultPreset {
|
||||||
|
NONE,
|
||||||
|
PLC_FIRST_SEND_FAILURE,
|
||||||
|
VEHICLE_RESTART,
|
||||||
|
LOW_BATTERY,
|
||||||
|
DISCONNECT_RECONNECT
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonProperty("sourceSite")
|
||||||
|
private String sourceSite;
|
||||||
|
@JsonProperty("targetSite")
|
||||||
|
private String targetSite;
|
||||||
|
@JsonProperty("taskType")
|
||||||
|
private String taskType;
|
||||||
|
@JsonProperty("priority")
|
||||||
|
private Integer priority;
|
||||||
|
@JsonProperty("acceleration")
|
||||||
|
private Integer acceleration;
|
||||||
|
@JsonProperty("historicalVehicleId")
|
||||||
|
private String historicalVehicleId;
|
||||||
|
@JsonProperty("historicalPath")
|
||||||
|
private List<String> historicalPath;
|
||||||
|
@JsonProperty("faultPreset")
|
||||||
|
private String faultPreset;
|
||||||
|
|
||||||
|
/** 供 Jackson 反序列化请求体使用。 */
|
||||||
|
public PointToPointSimulationRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建并立即校验一个请求,供测试和非 HTTP 调用方使用。
|
||||||
|
*/
|
||||||
|
public static PointToPointSimulationRequest valid(
|
||||||
|
String sourceSite, String targetSite, String taskType,
|
||||||
|
int priority, int acceleration, String historicalVehicleId,
|
||||||
|
List<String> historicalPath, String faultPreset) {
|
||||||
|
PointToPointSimulationRequest request = new PointToPointSimulationRequest();
|
||||||
|
request.sourceSite = sourceSite;
|
||||||
|
request.targetSite = targetSite;
|
||||||
|
request.taskType = taskType;
|
||||||
|
request.priority = priority;
|
||||||
|
request.acceleration = acceleration;
|
||||||
|
request.historicalVehicleId = historicalVehicleId;
|
||||||
|
request.historicalPath = historicalPath;
|
||||||
|
request.faultPreset = faultPreset;
|
||||||
|
request.validate();
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验并规范化来自页面的业务输入,避免非法参数进入调度器。
|
||||||
|
*/
|
||||||
|
public void validate() {
|
||||||
|
sourceSite = required(sourceSite, "sourceSite");
|
||||||
|
targetSite = required(targetSite, "targetSite");
|
||||||
|
taskType = required(taskType, "taskType").toUpperCase();
|
||||||
|
faultPreset = faultPreset == null || faultPreset.trim().isEmpty()
|
||||||
|
? FaultPreset.NONE.name() : faultPreset.trim().toUpperCase();
|
||||||
|
if (priority == null || priority < 0 || priority > 9) {
|
||||||
|
throw new IllegalArgumentException("INVALID_PRIORITY: priority must be between 0 and 9");
|
||||||
|
}
|
||||||
|
if (acceleration == null || (acceleration != 10 && acceleration != 100)) {
|
||||||
|
throw new IllegalArgumentException("INVALID_ACCELERATION: acceleration must be 10 or 100");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
DispatchTask.TaskType.valueOf(taskType);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw new IllegalArgumentException("INVALID_TASK_TYPE: " + taskType, exception);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
FaultPreset.valueOf(faultPreset);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw new IllegalArgumentException("INVALID_FAULT_PRESET: " + faultPreset, exception);
|
||||||
|
}
|
||||||
|
historicalVehicleId = trimToNull(historicalVehicleId);
|
||||||
|
historicalPath = immutablePath(historicalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(String value, String name) {
|
||||||
|
String normalized = trimToNull(value);
|
||||||
|
if (normalized == null) {
|
||||||
|
throw new IllegalArgumentException("BAD_REQUEST: " + name + " is required");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String normalized = value.trim();
|
||||||
|
return normalized.isEmpty() ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> immutablePath(List<String> source) {
|
||||||
|
if (source == null || source.isEmpty()) return Collections.emptyList();
|
||||||
|
List<String> result = new ArrayList<String>();
|
||||||
|
for (String node : source) {
|
||||||
|
String normalized = trimToNull(node);
|
||||||
|
if (normalized != null) result.add(normalized);
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableList(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSourceSite() { return sourceSite; }
|
||||||
|
public String getTargetSite() { return targetSite; }
|
||||||
|
public DispatchTask.TaskType getTaskType() { return DispatchTask.TaskType.valueOf(taskType); }
|
||||||
|
public int getPriority() { return priority; }
|
||||||
|
public int getAcceleration() { return acceleration; }
|
||||||
|
public String getHistoricalVehicleId() { return historicalVehicleId; }
|
||||||
|
public List<String> getHistoricalPath() { return historicalPath; }
|
||||||
|
public FaultPreset getFaultPreset() { return FaultPreset.valueOf(faultPreset); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.ft546.wcs.shadow.history.visualizer;
|
||||||
|
|
||||||
|
import com.ft546.wcs.demo.model.VehicleSnapshot;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulator;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.PointToPointSimulationInputFactory;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.SimulationConfig;
|
||||||
|
|
||||||
|
import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.CONNECT;
|
||||||
|
import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.DISCONNECT;
|
||||||
|
import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.LOW_BATTERY;
|
||||||
|
import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.RESTART;
|
||||||
|
import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.RESTORE_BATTERY;
|
||||||
|
import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.SYNCHRONIZE;
|
||||||
|
|
||||||
|
/** 调用现有中心调度器执行审计台单任务仿真。 */
|
||||||
|
public final class PointToPointSimulationService {
|
||||||
|
private final HistoricalSimulationInput baseInput;
|
||||||
|
private final PointToPointSimulationInputFactory inputFactory;
|
||||||
|
private final HistoricalDispatchSimulator simulator;
|
||||||
|
|
||||||
|
public PointToPointSimulationService(HistoricalSimulationInput baseInput) {
|
||||||
|
if (baseInput == null) {
|
||||||
|
throw new IllegalArgumentException("base input is required");
|
||||||
|
}
|
||||||
|
this.baseInput = baseInput;
|
||||||
|
this.inputFactory = new PointToPointSimulationInputFactory();
|
||||||
|
this.simulator = new HistoricalDispatchSimulator();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验页面输入,构造一个任务,并完整执行选车、规划、资源、PLC、移动和归档流程。
|
||||||
|
*/
|
||||||
|
public HistoricalDispatchSimulationResult run(PointToPointSimulationRequest request) {
|
||||||
|
if (request == null) throw new IllegalArgumentException("BAD_REQUEST: request is required");
|
||||||
|
request.validate();
|
||||||
|
HistoricalSimulationInput input = inputFactory.create(
|
||||||
|
baseInput, request.getSourceSite(), request.getTargetSite(),
|
||||||
|
request.getTaskType(), request.getPriority(),
|
||||||
|
request.getHistoricalVehicleId(), request.getHistoricalPath());
|
||||||
|
SimulationConfig config = new SimulationConfig(
|
||||||
|
request.getAcceleration(), 30_000L, 3, 500, 20_000);
|
||||||
|
return simulator.simulate(input, config, faultPlan(request.getFaultPreset()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeterministicFaultPlan faultPlan(PointToPointSimulationRequest.FaultPreset preset) {
|
||||||
|
if (preset == PointToPointSimulationRequest.FaultPreset.NONE) {
|
||||||
|
return DeterministicFaultPlan.none();
|
||||||
|
}
|
||||||
|
DeterministicFaultPlan.Builder builder = DeterministicFaultPlan.builder();
|
||||||
|
if (preset == PointToPointSimulationRequest.FaultPreset.PLC_FIRST_SEND_FAILURE) {
|
||||||
|
return builder.plcFailure(PointToPointSimulationInputFactory.TASK_ID, 1,
|
||||||
|
SimulatedPlcGateway.PlcReason.SEND_FAILURE).build();
|
||||||
|
}
|
||||||
|
for (VehicleSnapshot vehicle : baseInput.getInitialVehicles()) {
|
||||||
|
String vehicleId = vehicle.getVehicleId();
|
||||||
|
if (preset == PointToPointSimulationRequest.FaultPreset.VEHICLE_RESTART) {
|
||||||
|
builder.vehicleEvent(0, vehicleId, RESTART)
|
||||||
|
.vehicleEvent(2, vehicleId, SYNCHRONIZE);
|
||||||
|
} else if (preset == PointToPointSimulationRequest.FaultPreset.LOW_BATTERY) {
|
||||||
|
builder.vehicleEvent(0, vehicleId, LOW_BATTERY)
|
||||||
|
.vehicleEvent(2, vehicleId, RESTORE_BATTERY);
|
||||||
|
} else if (preset == PointToPointSimulationRequest.FaultPreset.DISCONNECT_RECONNECT) {
|
||||||
|
builder.vehicleEvent(0, vehicleId, DISCONNECT)
|
||||||
|
.vehicleEvent(2, vehicleId, CONNECT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.ft546.wcs.shadow.history.visualizer;
|
||||||
|
|
||||||
|
import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
|
||||||
|
import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInputFactory;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationTask;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.SimulationTaskResult;
|
||||||
|
import com.ft546.wcs.shadow.history.simulation.SimulationTaskStatus;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class PointToPointSimulationServiceTest {
|
||||||
|
private static HistoricalSimulationInput baseInput;
|
||||||
|
private static HistoricalSimulationTask sampleTask;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void loadBaseInput() {
|
||||||
|
HistoricalQueueDataset dataset = new HistoricalQueueLoader().load(
|
||||||
|
PointToPointSimulationServiceTest.class.getClassLoader(), "shadow/history");
|
||||||
|
baseInput = new HistoricalSimulationInputFactory().create(dataset);
|
||||||
|
sampleTask = baseInput.getTasks().stream()
|
||||||
|
.filter(task -> !task.getPhysicalSource().equals(task.getPhysicalTarget()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("a point-to-point sample is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runsOneTaskWithTheRealPlannerAndIgnoresHistoricalPath() {
|
||||||
|
PointToPointSimulationRequest request = PointToPointSimulationRequest.valid(
|
||||||
|
sampleTask.getPhysicalSource(), sampleTask.getPhysicalTarget(),
|
||||||
|
"INBOUND", 1, 100, "HISTORY-CAR",
|
||||||
|
Arrays.asList("WRONG", "REFERENCE"), "NONE");
|
||||||
|
|
||||||
|
HistoricalDispatchSimulationResult result = service().run(request);
|
||||||
|
SimulationTaskResult task = result.getTaskResults().get(0);
|
||||||
|
|
||||||
|
assertEquals(1, result.getTaskResults().size());
|
||||||
|
assertEquals(SimulationTaskStatus.COMPLETED, task.getStatus());
|
||||||
|
assertEquals(sampleTask.getPhysicalSource(), task.getPlannedPath().get(0));
|
||||||
|
assertEquals(sampleTask.getPhysicalTarget(),
|
||||||
|
task.getPlannedPath().get(task.getPlannedPath().size() - 1));
|
||||||
|
assertFalse(task.getPlannedPath().equals(Arrays.asList("WRONG", "REFERENCE")));
|
||||||
|
assertEquals("HISTORY-CAR", task.getRecord().getAgvId());
|
||||||
|
assertEquals(0, result.getInvariants().totalSafetyViolations());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownAndIdenticalEndpointsBeforeSimulation() {
|
||||||
|
PointToPointSimulationRequest unknown = PointToPointSimulationRequest.valid(
|
||||||
|
"UNKNOWN-SITE", sampleTask.getPhysicalTarget(), "INBOUND", 1,
|
||||||
|
100, null, Collections.emptyList(), "NONE");
|
||||||
|
PointToPointSimulationRequest identical = PointToPointSimulationRequest.valid(
|
||||||
|
sampleTask.getPhysicalSource(), sampleTask.getPhysicalSource(), "INBOUND", 1,
|
||||||
|
100, null, Collections.emptyList(), "NONE");
|
||||||
|
|
||||||
|
IllegalArgumentException unknownError = assertThrows(
|
||||||
|
IllegalArgumentException.class, () -> service().run(unknown));
|
||||||
|
IllegalArgumentException identicalError = assertThrows(
|
||||||
|
IllegalArgumentException.class, () -> service().run(identical));
|
||||||
|
|
||||||
|
assertTrue(unknownError.getMessage().contains("UNKNOWN_SITE"));
|
||||||
|
assertTrue(identicalError.getMessage().contains("SAME_ENDPOINT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plcFirstSendFailureProducesRetryEvidenceAndStillCompletes() {
|
||||||
|
PointToPointSimulationRequest request = PointToPointSimulationRequest.valid(
|
||||||
|
sampleTask.getPhysicalSource(), sampleTask.getPhysicalTarget(),
|
||||||
|
"OUTBOUND", 2, 100, null, Collections.emptyList(),
|
||||||
|
"PLC_FIRST_SEND_FAILURE");
|
||||||
|
|
||||||
|
HistoricalDispatchSimulationResult result = service().run(request);
|
||||||
|
|
||||||
|
assertEquals(1, result.getCompletedCount());
|
||||||
|
assertTrue(result.getRetryCount() > 0);
|
||||||
|
assertTrue(result.getEvents().stream()
|
||||||
|
.anyMatch(event -> "PLC_RETRY".equals(event.getType())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesPriorityAccelerationTaskTypeAndFaultPreset() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> PointToPointSimulationRequest.valid(
|
||||||
|
"A", "B", "INBOUND", 10, 100, null,
|
||||||
|
Collections.emptyList(), "NONE"));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> PointToPointSimulationRequest.valid(
|
||||||
|
"A", "B", "INBOUND", 1, 20, null,
|
||||||
|
Collections.emptyList(), "NONE"));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> PointToPointSimulationRequest.valid(
|
||||||
|
"A", "B", "UNKNOWN", 1, 100, null,
|
||||||
|
Collections.emptyList(), "NONE"));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> PointToPointSimulationRequest.valid(
|
||||||
|
"A", "B", "INBOUND", 1, 100, null,
|
||||||
|
Collections.emptyList(), "UNKNOWN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private PointToPointSimulationService service() {
|
||||||
|
return new PointToPointSimulationService(baseInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user