feat(wcs): coordinate lift joint resources

This commit is contained in:
zgw
2026-08-11 10:46:13 +08:00
parent 0d09eb93da
commit 6030deb227
8 changed files with 579 additions and 5 deletions

View File

@@ -0,0 +1,216 @@
package com.ruoyi.wcs.application.device;
import com.ruoyi.wcs.domain.equipment.LiftDecision;
import com.ruoyi.wcs.domain.equipment.LiftDecisionType;
import com.ruoyi.wcs.domain.equipment.LiftTransfer;
import com.ruoyi.wcs.domain.equipment.LiftTransferState;
import com.ruoyi.wcs.domain.execution.CommandOutboxEntry;
import com.ruoyi.wcs.domain.execution.DeviceCommand;
import com.ruoyi.wcs.domain.execution.DeviceSnapshot;
import com.ruoyi.wcs.domain.shared.IdGenerator;
import com.ruoyi.wcs.domain.traffic.ReservationDecision;
import com.ruoyi.wcs.domain.traffic.ReservationRequest;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.port.out.CommandOutboxRepository;
import com.ruoyi.wcs.port.out.DeviceCommandRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
class LiftCoordinatorTest {
private ResourceLedger ledger;
private FakeCommandRepository commands;
private FakeOutboxRepository outbox;
private LiftCoordinator coordinator;
@BeforeEach
void setUp() {
Map<String, Integer> capacities = new LinkedHashMap<String, Integer>();
capacities.put("LIFT_PORT:P1", 1);
capacities.put("LIFT:L1", 1);
capacities.put("LIFT_PORT:P2", 1);
ledger = new ResourceLedger(capacities);
commands = new FakeCommandRepository();
outbox = new FakeOutboxRepository();
coordinator = new LiftCoordinator(ledger, commands, outbox,
new IdGenerator() {
@Override
public String nextId() {
return "LIFT-CMD-1";
}
}, 7L, new java.util.function.Supplier<Instant>() {
@Override
public Instant get() {
return Instant.EPOCH;
}
});
}
@Test
void doesNotEnterLiftWhenTargetExitCannotBeReserved() {
ReservationDecision occupied = ledger.reserve(new ReservationRequest(
"OTHER-RES", "OTHER-ORDER", "OTHER-STEP", "OTHER",
Collections.singletonList("LIFT_PORT:P2")));
ledger.occupy("OTHER-RES", occupied.getFencingToken());
LiftDecision decision = coordinator.prepare(
"STEP-1", "V1", "L1", "P1", "P2");
assertEquals(LiftDecisionType.BLOCKED, decision.getType());
assertEquals(0, commands.values.size());
assertEquals(0, outbox.values.size());
}
@Test
void blockedPreparationCanRetryAfterTheTargetPortBecomesAvailable() {
ReservationDecision occupied = ledger.reserve(new ReservationRequest(
"OTHER-RES", "OTHER-ORDER", "OTHER-STEP", "OTHER",
Collections.singletonList("LIFT_PORT:P2")));
ledger.occupy("OTHER-RES", occupied.getFencingToken());
LiftDecision blocked = coordinator.prepare(
"STEP-1", "V1", "L1", "P1", "P2");
ledger.release("OTHER-RES", "OTHER-LEFT",
occupied.getFencingToken());
LiftDecision retried = coordinator.prepare(
"STEP-1", "V1", "L1", "P1", "P2");
assertEquals(LiftDecisionType.BLOCKED, blocked.getType());
assertEquals(LiftDecisionType.READY, retried.getType());
assertEquals(1, commands.values.size());
}
@Test
void releasesLiftOnlyAfterVehicleLeavesTargetPort() {
LiftDecision prepared = coordinator.prepare(
"STEP-1", "V1", "L1", "P1", "P2");
LiftTransfer transfer = prepared.getTransfer();
transfer.vehicleEnteredLift("VEHICLE-IN-LIFT");
transfer.liftStartedMoving("LIFT-MOVING");
transfer.arrivedAtTarget("AT-TARGET");
transfer.commitTargetExit("TARGET-EXIT");
LiftDecision completed = coordinator.onVehicleLeftTarget(
transfer, matchingFacts());
assertEquals(LiftDecisionType.COMPLETED, completed.getType());
assertEquals(LiftTransferState.COMPLETED, transfer.getState());
assertEquals(1, ledger.availableCapacity("LIFT:L1"));
assertEquals(1, ledger.availableCapacity("LIFT_PORT:P2"));
}
@Test
void mismatchingDoorOrFloorFactsKeepAllJointResourcesIsolated() {
LiftTransfer transfer = coordinator.prepare(
"STEP-1", "V1", "L1", "P1", "P2").getTransfer();
transfer.vehicleEnteredLift("VEHICLE-IN-LIFT");
transfer.liftStartedMoving("LIFT-MOVING");
transfer.arrivedAtTarget("AT-TARGET");
transfer.commitTargetExit("TARGET-EXIT");
Map<String, String> facts = matchingFactMap();
facts.put("liftDoorOpen", "false");
LiftDecision decision = coordinator.onVehicleLeftTarget(
transfer, snapshot(facts));
assertEquals(LiftDecisionType.RECOVERY_REQUIRED, decision.getType());
assertEquals(0, ledger.availableCapacity("LIFT:L1"));
assertEquals(0, ledger.availableCapacity("LIFT_PORT:P2"));
}
@Test
void laterMatchingCombinedFactsCanResolveTheIsolationAndRelease() {
LiftTransfer transfer = coordinator.prepare(
"STEP-1", "V1", "L1", "P1", "P2").getTransfer();
transfer.vehicleEnteredLift("VEHICLE-IN-LIFT");
transfer.liftStartedMoving("LIFT-MOVING");
transfer.arrivedAtTarget("AT-TARGET");
transfer.commitTargetExit("TARGET-EXIT");
Map<String, String> mismatch = matchingFactMap();
mismatch.put("liftFloor", "P1");
coordinator.onVehicleLeftTarget(transfer, snapshot(mismatch));
LiftDecision recovered = coordinator.onVehicleLeftTarget(
transfer, matchingFacts());
assertEquals(LiftDecisionType.COMPLETED, recovered.getType());
assertEquals(1, ledger.availableCapacity("LIFT:L1"));
}
private static DeviceSnapshot matchingFacts() {
return snapshot(matchingFactMap());
}
private static Map<String, String> matchingFactMap() {
Map<String, String> facts = new LinkedHashMap<String, String>();
facts.put("liftId", "L1");
facts.put("liftFloor", "P2");
facts.put("liftDoorOpen", "true");
facts.put("targetPortClear", "true");
return facts;
}
private static DeviceSnapshot snapshot(Map<String, String> facts) {
return new DeviceSnapshot("LIFT-SNAPSHOT-1", "V1",
"LIFT:STEP-1", "STEP-1", 10L, Instant.EPOCH.plusSeconds(10),
true, "BOOT-1", 7L, 1L, "P2",
Collections.singletonList("P2"), true, facts);
}
private static final class FakeCommandRepository
implements DeviceCommandRepository {
private final Map<String, DeviceCommand> values =
new LinkedHashMap<String, DeviceCommand>();
@Override
public DeviceCommand findByCommandKey(String commandKey) {
for (DeviceCommand command : values.values()) {
if (command.getCommandKey().equals(commandKey)) {
return command;
}
}
return null;
}
@Override
public DeviceCommand findByCommandId(String commandId) {
return values.get(commandId);
}
@Override
public void save(DeviceCommand command) {
values.put(command.getCommandId(), command);
}
}
private static final class FakeOutboxRepository
implements CommandOutboxRepository {
private final Map<String, CommandOutboxEntry> values =
new LinkedHashMap<String, CommandOutboxEntry>();
@Override
public void saveIfAbsent(CommandOutboxEntry entry) {
if (!values.containsKey(entry.getCommandId())) {
values.put(entry.getCommandId(), entry);
}
}
@Override
public void save(CommandOutboxEntry entry) {
values.put(entry.getCommandId(), entry);
}
@Override
public List<CommandOutboxEntry> claimDue(int limit, Instant now) {
return Collections.emptyList();
}
}
}