diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/DemoApplication.java b/javaTest/src/main/java/com/ft546/wcs/demo/DemoApplication.java index a7f35f7..c91de9a 100644 --- a/javaTest/src/main/java/com/ft546/wcs/demo/DemoApplication.java +++ b/javaTest/src/main/java/com/ft546/wcs/demo/DemoApplication.java @@ -3,6 +3,7 @@ package com.ft546.wcs.demo; import com.ft546.wcs.demo.center.CycleResult; import com.ft546.wcs.demo.center.LegacyDispatchCenter; import com.ft546.wcs.demo.center.QueueAdmissionService; +import com.ft546.wcs.demo.center.VehicleAssignmentDecision; import com.ft546.wcs.demo.center.VehicleAssignmentService; import com.ft546.wcs.demo.model.DispatchTask; import com.ft546.wcs.demo.model.Site; @@ -14,6 +15,7 @@ import com.ft546.wcs.demo.traffic.GridPathPlanner; import com.ft546.wcs.demo.traffic.TrafficController; import java.io.PrintStream; +import java.util.Arrays; public final class DemoApplication { @@ -58,6 +60,7 @@ public final class DemoApplication { out.println("FT546 WCS LEGACY DISPATCH AND TRAFFIC DEMO"); out.println("MODE LEGACY / IO DISABLED / IN-MEMORY ONLY"); + printCrossFloorAssignmentCheck(out, map); for (int cycle = 0; cycle < 6; cycle++) { printCycle(out, center.runCycle()); @@ -87,6 +90,30 @@ public final class DemoApplication { } } + private static void printCrossFloorAssignmentCheck(PrintStream out, GridMap map) { + DispatchTask checkTask = new DispatchTask( + "CHECK-CROSS-FLOOR", + DispatchTask.TaskType.OUTBOUND, + "A", + "TARGET", + 1, + 1 + ); + VehicleAssignmentDecision decision = new VehicleAssignmentService().select( + checkTask, + Arrays.asList( + new VehicleSnapshot("1", 2, "F2-NEAR", 5, 1, 80, true, true, false), + new VehicleSnapshot("2", 1, "F1-FAR", 5, 1, 80, true, true, false) + ), + map.getSites() + ); + out.println( + "ASSIGN-CHECK crossFloorWinner=" + + decision.getVehicle().get().getVehicleId() + + " reason=" + decision.getReason() + ); + } + private static GridMap createMap() { GridMap map = new GridMap(); map.addSite(new Site("A", 1, 0, 0, "START")); @@ -95,6 +122,8 @@ public final class DemoApplication { map.addSite(new Site("CACHE", 1, 1, 1, "CACHE")); map.addSite(new Site("V3", 1, 0, 1, "V3-CHANNEL")); map.addSite(new Site("ISOLATED", 1, 9, 9, "ISLAND")); + map.addSite(new Site("F2-NEAR", 2, 0, 0, "F2-CHECK")); + map.addSite(new Site("F1-FAR", 1, 9, 9, "F1-CHECK")); map.connect("A", "B"); map.connect("B", "TARGET"); map.connect("B", "CACHE"); diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/center/LegacyDispatchCenter.java b/javaTest/src/main/java/com/ft546/wcs/demo/center/LegacyDispatchCenter.java index 436ebaa..2c61013 100644 --- a/javaTest/src/main/java/com/ft546/wcs/demo/center/LegacyDispatchCenter.java +++ b/javaTest/src/main/java/com/ft546/wcs/demo/center/LegacyDispatchCenter.java @@ -16,13 +16,13 @@ import java.util.Optional; public final class LegacyDispatchCenter { private final InMemoryWcsState state; - private final QueueAdmissionService admission; + private final QueueAdmission admission; private final TrafficController traffic; private final SimulatedProtocolAdapter protocol; public LegacyDispatchCenter( InMemoryWcsState state, - QueueAdmissionService admission, + QueueAdmission admission, TrafficController traffic, SimulatedProtocolAdapter protocol ) { @@ -42,8 +42,6 @@ public final class LegacyDispatchCenter { int cycleNumber = state.nextCycle(); state.addEvent("CYCLE " + cycleNumber); - admission.createPendingQueue(state); - List vehicles = new ArrayList(state.getVehicles()); Collections.sort(vehicles, new Comparator() { @@ -53,6 +51,8 @@ public final class LegacyDispatchCenter { } }); + admission.createPendingQueue(state, vehicles); + List processed = new ArrayList(); for (VehicleSnapshot frozenVehicle : vehicles) { processed.add(frozenVehicle.getVehicleId()); diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/center/QueueAdmission.java b/javaTest/src/main/java/com/ft546/wcs/demo/center/QueueAdmission.java new file mode 100644 index 0000000..18ee7eb --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/center/QueueAdmission.java @@ -0,0 +1,17 @@ +package com.ft546.wcs.demo.center; + +import com.ft546.wcs.demo.model.DispatchQueueEntry; +import com.ft546.wcs.demo.model.VehicleSnapshot; +import com.ft546.wcs.demo.repository.InMemoryWcsState; + +import java.util.List; +import java.util.Optional; + +public interface QueueAdmission { + Optional createPendingQueue( + InMemoryWcsState state, + List frozenVehicles + ); + + int getInvocationCount(); +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/center/QueueAdmissionService.java b/javaTest/src/main/java/com/ft546/wcs/demo/center/QueueAdmissionService.java index 7dd084e..e9bda67 100644 --- a/javaTest/src/main/java/com/ft546/wcs/demo/center/QueueAdmissionService.java +++ b/javaTest/src/main/java/com/ft546/wcs/demo/center/QueueAdmissionService.java @@ -10,7 +10,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; -public final class QueueAdmissionService { +public final class QueueAdmissionService implements QueueAdmission { private final VehicleAssignmentService assignmentService; private final GridMap map; private int invocationCount; @@ -26,9 +26,13 @@ public final class QueueAdmissionService { this.map = map; } - public Optional createPendingQueue(InMemoryWcsState state) { - if (state == null) { - throw new IllegalArgumentException("state must not be null"); + @Override + public Optional createPendingQueue( + InMemoryWcsState state, + List frozenVehicles + ) { + if (state == null || frozenVehicles == null) { + throw new IllegalArgumentException("state and frozen vehicles are required"); } invocationCount++; if (state.getPendingTasks().isEmpty()) { @@ -37,7 +41,7 @@ public final class QueueAdmissionService { DispatchTask task = state.getPendingTasks().get(0); List available = new ArrayList(); - for (VehicleSnapshot vehicle : state.getVehicles()) { + for (VehicleSnapshot vehicle : frozenVehicles) { if (!state.findQueueByVehicle(vehicle.getVehicleId()).isPresent()) { available.add(vehicle); } @@ -65,6 +69,7 @@ public final class QueueAdmissionService { return Optional.of(queue); } + @Override public int getInvocationCount() { return invocationCount; } diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentService.java b/javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentService.java index d6b300e..d44e8ca 100644 --- a/javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentService.java +++ b/javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentService.java @@ -28,6 +28,12 @@ public final class VehicleAssignmentService { "unknown task source site: " + task.getSourceSite() ); } + if (!sites.containsKey(task.getTargetSite())) { + return VehicleAssignmentDecision.rejected( + DecisionCode.REJECT_INVALID_SITE, + "unknown task target site: " + task.getTargetSite() + ); + } List candidates = new ArrayList(); boolean eligibleWithUnknownSite = false; diff --git a/javaTest/src/test/java/com/ft546/wcs/demo/DemoScenarioTest.java b/javaTest/src/test/java/com/ft546/wcs/demo/DemoScenarioTest.java index 4db50a7..4ec78ba 100644 --- a/javaTest/src/test/java/com/ft546/wcs/demo/DemoScenarioTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/demo/DemoScenarioTest.java @@ -18,6 +18,7 @@ class DemoScenarioTest { DemoApplication.runScenario(new PrintStream(output, true, "UTF-8")); String text = new String(output.toByteArray(), StandardCharsets.UTF_8); + assertTrue(text.contains("ASSIGN-CHECK crossFloorWinner=2")); assertTrue(text.contains("CYCLE")); assertTrue(text.contains("ASSIGN")); assertTrue(text.contains("PATH")); @@ -27,6 +28,24 @@ class DemoScenarioTest { assertTrue(text.contains("HOLD HOLD_NO_PATH")); assertTrue(text.contains("SEND")); assertTrue(text.contains("COMPLETE")); - assertTrue(text.contains("SUMMARY cycles=7 activeQueues=1 history=2")); + assertTrue(text.contains( + "SUMMARY cycles=7 activeQueues=1 history=2 protocolAttempts=3" + )); + + int assign = text.indexOf("ASSIGN task=TASK-OUT-1"); + int avoid = text.indexOf("AVOID queue="); + int protocolHold = text.indexOf("HOLD HOLD_PROTOCOL_FAILURE"); + int send = text.indexOf("SEND queue=AVOID"); + int avoidanceComplete = text.indexOf("COMPLETE queue=AVOID"); + int businessComplete = text.indexOf("COMPLETE queue=TASK-OUT-1"); + int noPath = text.indexOf("HOLD HOLD_NO_PATH"); + int summary = text.indexOf("SUMMARY"); + assertTrue(assign < avoid); + assertTrue(avoid < protocolHold); + assertTrue(protocolHold < send); + assertTrue(send < avoidanceComplete); + assertTrue(avoidanceComplete < businessComplete); + assertTrue(businessComplete < noPath); + assertTrue(noPath < summary); } } diff --git a/javaTest/src/test/java/com/ft546/wcs/demo/LegacyDispatchCenterTest.java b/javaTest/src/test/java/com/ft546/wcs/demo/LegacyDispatchCenterTest.java index 09d7f5b..9ad19dd 100644 --- a/javaTest/src/test/java/com/ft546/wcs/demo/LegacyDispatchCenterTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/demo/LegacyDispatchCenterTest.java @@ -2,6 +2,7 @@ package com.ft546.wcs.demo; import com.ft546.wcs.demo.center.CycleResult; import com.ft546.wcs.demo.center.LegacyDispatchCenter; +import com.ft546.wcs.demo.center.QueueAdmission; import com.ft546.wcs.demo.center.QueueAdmissionService; import com.ft546.wcs.demo.center.VehicleAssignmentService; import com.ft546.wcs.demo.model.DispatchQueueEntry; @@ -16,6 +17,8 @@ import com.ft546.wcs.demo.traffic.TrafficController; import org.junit.jupiter.api.Test; import java.util.Arrays; +import java.util.List; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -93,6 +96,43 @@ class LegacyDispatchCenterTest { assertTrue(fixture.state.getEvents().toString().contains("AVOID")); } + @Test + void vehicleSnapshotIsFrozenBeforeAdmissionMutatesLiveState() { + GridMap map = simpleMap(); + InMemoryWcsState state = new InMemoryWcsState(); + state.addVehicle(vehicle("1", "A")); + QueueAdmission admission = new QueueAdmission() { + private int invocationCount; + + @Override + public Optional createPendingQueue( + InMemoryWcsState liveState, + List frozenVehicles + ) { + invocationCount++; + liveState.addVehicle(vehicle("9", "V3")); + return Optional.empty(); + } + + @Override + public int getInvocationCount() { + return invocationCount; + } + }; + LegacyDispatchCenter center = new LegacyDispatchCenter( + state, + admission, + new TrafficController(map, new GridPathPlanner()), + new SimulatedProtocolAdapter() + ); + + CycleResult result = center.runCycle(); + + assertEquals(Arrays.asList("1"), result.getProcessedVehicleIds()); + assertEquals(2, state.getVehicles().size()); + assertEquals(1, admission.getInvocationCount()); + } + private Fixture fixtureWithThreeVehicles() { GridMap map = simpleMap(); InMemoryWcsState state = new InMemoryWcsState(); diff --git a/javaTest/src/test/java/com/ft546/wcs/demo/VehicleAssignmentServiceTest.java b/javaTest/src/test/java/com/ft546/wcs/demo/VehicleAssignmentServiceTest.java index 19df7c6..96aa32c 100644 --- a/javaTest/src/test/java/com/ft546/wcs/demo/VehicleAssignmentServiceTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/demo/VehicleAssignmentServiceTest.java @@ -28,6 +28,10 @@ class VehicleAssignmentServiceTest { sites.put("SAME", new Site("SAME", 1, 1, 1, "AISLE-1")); sites.put("NEAR", new Site("NEAR", 2, 10, 9, "AISLE-2")); sites.put("TIE", new Site("TIE", 1, 1, 1, "AISLE-1")); + sites.put("Y_NEAR_X_FAR", new Site("Y_NEAR_X_FAR", 1, 0, 9, "AISLE-3")); + sites.put("Y_FAR_X_NEAR", new Site("Y_FAR_X_NEAR", 1, 9, 0, "AISLE-4")); + sites.put("X_NEAR", new Site("X_NEAR", 1, 9, 9, "AISLE-5")); + sites.put("X_FAR", new Site("X_FAR", 1, 0, 9, "AISLE-6")); } @Test @@ -85,6 +89,56 @@ class VehicleAssignmentServiceTest { assertTrue(decision.getReason().contains("UNKNOWN")); } + @Test + void missingTargetSiteIsRejectedBeforeAQueueCanBeCreated() { + DispatchTask invalidTarget = new DispatchTask( + "TASK-TARGET", + DispatchTask.TaskType.OUTBOUND, + "SOURCE", + "UNKNOWN", + 1, + 2 + ); + + VehicleAssignmentDecision decision = service.select( + invalidTarget, + Arrays.asList(vehicle("1", 1, "SAME", 80, true, true, 5, 1)), + sites + ); + + assertEquals(DecisionCode.REJECT_INVALID_SITE, decision.getCode()); + assertFalse(decision.getVehicle().isPresent()); + assertTrue(decision.getReason().contains("UNKNOWN")); + } + + @Test + void legacyDistanceUsesYBeforeX() { + VehicleSnapshot yNear = + vehicle("2", 1, "Y_NEAR_X_FAR", 80, true, true, 5, 1); + VehicleSnapshot xNear = + vehicle("1", 1, "Y_FAR_X_NEAR", 80, true, true, 5, 1); + + VehicleAssignmentDecision decision = + service.select(task("SOURCE"), Arrays.asList(xNear, yNear), sites); + + assertEquals("2", decision.getVehicle().get().getVehicleId()); + assertTrue(decision.getReason().contains("yDistance=1")); + } + + @Test + void legacyDistanceUsesXWhenYIsTied() { + VehicleSnapshot xNear = + vehicle("2", 1, "X_NEAR", 80, true, true, 5, 1); + VehicleSnapshot xFar = + vehicle("1", 1, "X_FAR", 80, true, true, 5, 1); + + VehicleAssignmentDecision decision = + service.select(task("SOURCE"), Arrays.asList(xFar, xNear), sites); + + assertEquals("2", decision.getVehicle().get().getVehicleId()); + assertTrue(decision.getReason().contains("xDistance=1")); + } + private DispatchTask task(String sourceSite) { return new DispatchTask( "TASK-1",