feat(javaTest): add point-to-point simulation service

This commit is contained in:
zgw
2026-08-05 14:59:44 +08:00
parent 4e562077b1
commit ca532f3c05
4 changed files with 375 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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