diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/center/CycleResult.java b/javaTest/src/main/java/com/ft546/wcs/demo/center/CycleResult.java new file mode 100644 index 0000000..d2c0a73 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/center/CycleResult.java @@ -0,0 +1,35 @@ +package com.ft546.wcs.demo.center; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class CycleResult { + private final int cycleNumber; + private final List processedVehicleIds; + private final List events; + + public CycleResult( + int cycleNumber, + List processedVehicleIds, + List events + ) { + this.cycleNumber = cycleNumber; + this.processedVehicleIds = Collections.unmodifiableList( + new ArrayList(processedVehicleIds) + ); + this.events = Collections.unmodifiableList(new ArrayList(events)); + } + + public int getCycleNumber() { + return cycleNumber; + } + + public List getProcessedVehicleIds() { + return processedVehicleIds; + } + + public List getEvents() { + return events; + } +} 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 new file mode 100644 index 0000000..436ebaa --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/center/LegacyDispatchCenter.java @@ -0,0 +1,154 @@ +package com.ft546.wcs.demo.center; + +import com.ft546.wcs.demo.model.DecisionCode; +import com.ft546.wcs.demo.model.DispatchQueueEntry; +import com.ft546.wcs.demo.model.VehicleSnapshot; +import com.ft546.wcs.demo.repository.InMemoryWcsState; +import com.ft546.wcs.demo.simulation.SimulatedProtocolAdapter; +import com.ft546.wcs.demo.traffic.TrafficController; +import com.ft546.wcs.demo.traffic.TrafficDecision; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +public final class LegacyDispatchCenter { + private final InMemoryWcsState state; + private final QueueAdmissionService admission; + private final TrafficController traffic; + private final SimulatedProtocolAdapter protocol; + + public LegacyDispatchCenter( + InMemoryWcsState state, + QueueAdmissionService admission, + TrafficController traffic, + SimulatedProtocolAdapter protocol + ) { + if (state == null || admission == null || traffic == null || protocol == null) { + throw new IllegalArgumentException( + "state, admission, traffic and protocol are required" + ); + } + this.state = state; + this.admission = admission; + this.traffic = traffic; + this.protocol = protocol; + } + + public CycleResult runCycle() { + int eventStart = state.getEvents().size(); + int cycleNumber = state.nextCycle(); + state.addEvent("CYCLE " + cycleNumber); + + admission.createPendingQueue(state); + + List vehicles = + new ArrayList(state.getVehicles()); + Collections.sort(vehicles, new Comparator() { + @Override + public int compare(VehicleSnapshot left, VehicleSnapshot right) { + return left.getVehicleId().compareTo(right.getVehicleId()); + } + }); + + List processed = new ArrayList(); + for (VehicleSnapshot frozenVehicle : vehicles) { + processed.add(frozenVehicle.getVehicleId()); + Optional optional = + state.findQueueByVehicle(frozenVehicle.getVehicleId()); + if (!optional.isPresent()) { + continue; + } + DispatchQueueEntry queue = optional.get(); + if (queue.isIssued()) { + processIssuedQueue(frozenVehicle, queue); + } else { + processUnissuedQueue(frozenVehicle, queue); + } + } + + List cycleEvents = new ArrayList( + state.getEvents().subList(eventStart, state.getEvents().size()) + ); + return new CycleResult(cycleNumber, processed, cycleEvents); + } + + private void processUnissuedQueue( + VehicleSnapshot vehicle, + DispatchQueueEntry queue + ) { + TrafficDecision decision = traffic.plan( + vehicle, + queue, + state.getVehicles(), + state.getActiveQueues() + ); + if (decision.getCode() == DecisionCode.AVOIDANCE_CREATED) { + DispatchQueueEntry avoidance = decision.getAvoidanceQueue().get(); + state.addQueue(avoidance); + state.addEvent( + "AVOID queue=" + avoidance.getQueueId() + " reason=" + + decision.getReason() + ); + return; + } + if (decision.getCode() != DecisionCode.PATH_READY) { + state.addEvent( + "HOLD " + decision.getCode() + " queue=" + queue.getQueueId() + + " reason=" + decision.getReason() + ); + return; + } + + state.addEvent("PATH queue=" + queue.getQueueId() + " " + decision.getPath()); + SimulatedProtocolAdapter.ProtocolResult result = + protocol.send(queue, decision.getPath()); + if (!result.isSuccess()) { + state.addEvent( + "HOLD " + DecisionCode.HOLD_PROTOCOL_FAILURE + " queue=" + + queue.getQueueId() + " reason=" + result.getMessage() + ); + return; + } + queue.markIssued(decision.getPath()); + state.addEvent("SEND queue=" + queue.getQueueId() + " vehicle=" + + queue.getVehicleId()); + } + + private void processIssuedQueue( + VehicleSnapshot vehicle, + DispatchQueueEntry queue + ) { + if (vehicle.getCurrentSite().equals(queue.getEndSite())) { + complete(queue); + return; + } + int index = queue.getPath().indexOf(vehicle.getCurrentSite()); + if (index < 0 || index + 1 >= queue.getPath().size()) { + state.addEvent( + "HOLD " + DecisionCode.HOLD_NO_PATH + " queue=" + queue.getQueueId() + + " reason=vehicle is outside issued path" + ); + return; + } + String nextSite = queue.getPath().get(index + 1); + state.updateVehicle(vehicle.at(nextSite, vehicle.getFloor())); + state.addEvent( + "MOVE vehicle=" + vehicle.getVehicleId() + " site=" + nextSite + ); + if (nextSite.equals(queue.getEndSite())) { + complete(queue); + } + } + + private void complete(DispatchQueueEntry queue) { + if (state.completeQueue(queue.getQueueId())) { + state.addEvent( + "COMPLETE queue=" + queue.getQueueId() + " vehicle=" + + queue.getVehicleId() + ); + } + } +} 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 new file mode 100644 index 0000000..7dd084e --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/center/QueueAdmissionService.java @@ -0,0 +1,71 @@ +package com.ft546.wcs.demo.center; + +import com.ft546.wcs.demo.model.DispatchQueueEntry; +import com.ft546.wcs.demo.model.DispatchTask; +import com.ft546.wcs.demo.model.VehicleSnapshot; +import com.ft546.wcs.demo.repository.InMemoryWcsState; +import com.ft546.wcs.demo.traffic.GridMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public final class QueueAdmissionService { + private final VehicleAssignmentService assignmentService; + private final GridMap map; + private int invocationCount; + + public QueueAdmissionService( + VehicleAssignmentService assignmentService, + GridMap map + ) { + if (assignmentService == null || map == null) { + throw new IllegalArgumentException("assignment service and map are required"); + } + this.assignmentService = assignmentService; + this.map = map; + } + + public Optional createPendingQueue(InMemoryWcsState state) { + if (state == null) { + throw new IllegalArgumentException("state must not be null"); + } + invocationCount++; + if (state.getPendingTasks().isEmpty()) { + return Optional.empty(); + } + + DispatchTask task = state.getPendingTasks().get(0); + List available = new ArrayList(); + for (VehicleSnapshot vehicle : state.getVehicles()) { + if (!state.findQueueByVehicle(vehicle.getVehicleId()).isPresent()) { + available.add(vehicle); + } + } + VehicleAssignmentDecision decision = + assignmentService.select(task, available, map.getSites()); + if (!decision.getVehicle().isPresent()) { + state.addEvent( + "HOLD " + decision.getCode() + " task=" + task.getTaskId() + + " reason=" + decision.getReason() + ); + return Optional.empty(); + } + + VehicleSnapshot selected = decision.getVehicle().get(); + DispatchQueueEntry queue = DispatchQueueEntry.business( + task, + selected.getVehicleId() + ); + state.admitTask(task, queue); + state.addEvent( + "ASSIGN task=" + task.getTaskId() + " vehicle=" + + selected.getVehicleId() + " reason=" + decision.getReason() + ); + return Optional.of(queue); + } + + public int getInvocationCount() { + return invocationCount; + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/repository/InMemoryWcsState.java b/javaTest/src/main/java/com/ft546/wcs/demo/repository/InMemoryWcsState.java new file mode 100644 index 0000000..2a1284f --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/repository/InMemoryWcsState.java @@ -0,0 +1,152 @@ +package com.ft546.wcs.demo.repository; + +import com.ft546.wcs.demo.model.DispatchQueueEntry; +import com.ft546.wcs.demo.model.DispatchTask; +import com.ft546.wcs.demo.model.VehicleSnapshot; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public final class InMemoryWcsState { + private final Map vehicles = + new LinkedHashMap(); + private final Map pendingTasks = + new LinkedHashMap(); + private final Set knownTaskIds = new LinkedHashSet(); + private final Map activeQueues = + new LinkedHashMap(); + private final List history = + new ArrayList(); + private final List events = new ArrayList(); + private int cycleNumber; + + public void addVehicle(VehicleSnapshot vehicle) { + if (vehicle == null) { + throw new IllegalArgumentException("vehicle must not be null"); + } + if (vehicles.containsKey(vehicle.getVehicleId())) { + throw new IllegalArgumentException( + "duplicate vehicle: " + vehicle.getVehicleId() + ); + } + vehicles.put(vehicle.getVehicleId(), vehicle); + } + + public void updateVehicle(VehicleSnapshot vehicle) { + if (vehicle == null || !vehicles.containsKey(vehicle.getVehicleId())) { + throw new IllegalArgumentException("cannot update unknown vehicle"); + } + vehicles.put(vehicle.getVehicleId(), vehicle); + } + + public void addTask(DispatchTask task) { + if (task == null) { + throw new IllegalArgumentException("task must not be null"); + } + if (!knownTaskIds.add(task.getTaskId())) { + throw new IllegalArgumentException("duplicate task: " + task.getTaskId()); + } + pendingTasks.put(task.getTaskId(), task); + } + + public void admitTask(DispatchTask task, DispatchQueueEntry queue) { + if (task == null || !pendingTasks.containsKey(task.getTaskId())) { + throw new IllegalArgumentException("task is not pending: " + task); + } + addQueue(queue); + pendingTasks.remove(task.getTaskId()); + } + + public void addQueue(DispatchQueueEntry queue) { + if (queue == null) { + throw new IllegalArgumentException("queue must not be null"); + } + if (activeQueues.containsKey(queue.getQueueId())) { + throw new IllegalArgumentException("duplicate queue: " + queue.getQueueId()); + } + if (findQueueByVehicle(queue.getVehicleId()).isPresent()) { + throw new IllegalStateException( + "vehicle already has an active queue: " + queue.getVehicleId() + ); + } + activeQueues.put(queue.getQueueId(), queue); + } + + public boolean completeQueue(String queueId) { + DispatchQueueEntry queue = activeQueues.remove(queueId); + if (queue == null) { + return false; + } + queue.markCompleted(); + history.add(queue); + return true; + } + + public List getVehicles() { + return Collections.unmodifiableList( + new ArrayList(vehicles.values()) + ); + } + + public List getPendingTasks() { + List result = + new ArrayList(pendingTasks.values()); + Collections.sort(result, new Comparator() { + @Override + public int compare(DispatchTask left, DispatchTask right) { + int priority = Integer.compare(right.getPriority(), left.getPriority()); + if (priority != 0) { + return priority; + } + return left.getTaskId().compareTo(right.getTaskId()); + } + }); + return Collections.unmodifiableList(result); + } + + public List getActiveQueues() { + return Collections.unmodifiableList( + new ArrayList(activeQueues.values()) + ); + } + + public List getHistory() { + return Collections.unmodifiableList(history); + } + + public Optional findQueueByVehicle(String vehicleId) { + for (DispatchQueueEntry queue : activeQueues.values()) { + if (queue.getVehicleId().equals(vehicleId)) { + return Optional.of(queue); + } + } + return Optional.empty(); + } + + public void addEvent(String event) { + if (event == null || event.trim().isEmpty()) { + throw new IllegalArgumentException("event must not be blank"); + } + events.add(event); + } + + public List getEvents() { + return Collections.unmodifiableList(events); + } + + public int nextCycle() { + cycleNumber++; + return cycleNumber; + } + + public int getCycleNumber() { + return cycleNumber; + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/simulation/SimulatedProtocolAdapter.java b/javaTest/src/main/java/com/ft546/wcs/demo/simulation/SimulatedProtocolAdapter.java new file mode 100644 index 0000000..46822ab --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/simulation/SimulatedProtocolAdapter.java @@ -0,0 +1,62 @@ +package com.ft546.wcs.demo.simulation; + +import com.ft546.wcs.demo.model.DispatchQueueEntry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class SimulatedProtocolAdapter { + private final List attemptedQueueIds = new ArrayList(); + private String nextFailure; + + public ProtocolResult send(DispatchQueueEntry queue, List path) { + if (queue == null || path == null || path.isEmpty()) { + throw new IllegalArgumentException("queue and non-empty path are required"); + } + attemptedQueueIds.add(queue.getQueueId()); + if (nextFailure != null) { + String failure = nextFailure; + nextFailure = null; + return ProtocolResult.failure(failure); + } + return ProtocolResult.success(); + } + + public void failNextSend(String reason) { + if (reason == null || reason.trim().isEmpty()) { + throw new IllegalArgumentException("failure reason must not be blank"); + } + nextFailure = reason; + } + + public List getAttemptedQueueIds() { + return Collections.unmodifiableList(attemptedQueueIds); + } + + public static final class ProtocolResult { + private final boolean success; + private final String message; + + private ProtocolResult(boolean success, String message) { + this.success = success; + this.message = message; + } + + public static ProtocolResult success() { + return new ProtocolResult(true, "accepted"); + } + + public static ProtocolResult failure(String message) { + return new ProtocolResult(false, message); + } + + public boolean isSuccess() { + return success; + } + + public String getMessage() { + return message; + } + } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/demo/LegacyDispatchCenterTest.java b/javaTest/src/test/java/com/ft546/wcs/demo/LegacyDispatchCenterTest.java new file mode 100644 index 0000000..09d7f5b --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/demo/LegacyDispatchCenterTest.java @@ -0,0 +1,178 @@ +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.VehicleAssignmentService; +import com.ft546.wcs.demo.model.DispatchQueueEntry; +import com.ft546.wcs.demo.model.DispatchTask; +import com.ft546.wcs.demo.model.Site; +import com.ft546.wcs.demo.model.VehicleSnapshot; +import com.ft546.wcs.demo.repository.InMemoryWcsState; +import com.ft546.wcs.demo.simulation.SimulatedProtocolAdapter; +import com.ft546.wcs.demo.traffic.GridMap; +import com.ft546.wcs.demo.traffic.GridPathPlanner; +import com.ft546.wcs.demo.traffic.TrafficController; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +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 LegacyDispatchCenterTest { + + @Test + void eachCycleAdmitsOnceAndProcessesVehiclesInStableOrder() { + Fixture fixture = fixtureWithThreeVehicles(); + + CycleResult result = fixture.center.runCycle(); + + assertEquals(1, fixture.admission.getInvocationCount()); + assertEquals(Arrays.asList("1", "2", "3"), result.getProcessedVehicleIds()); + assertEquals(1, fixture.state.getActiveQueues().size()); + assertTrue(fixture.state.getActiveQueues().get(0).isIssued()); + } + + @Test + void failedProtocolSendRemainsPendingAndRetriesNextCycle() { + Fixture fixture = fixtureWithOneVehicle(); + fixture.protocol.failNextSend("simulated PLC rejection"); + + fixture.center.runCycle(); + + DispatchQueueEntry pending = fixture.state.getActiveQueues().get(0); + assertEquals(0, pending.getIssued()); + assertEquals(1, pending.getExecutionStatus()); + assertTrue(fixture.state.getEvents().toString().contains("HOLD_PROTOCOL_FAILURE")); + + fixture.center.runCycle(); + + assertEquals(1, pending.getIssued()); + assertEquals(3, pending.getExecutionStatus()); + assertEquals(2, fixture.protocol.getAttemptedQueueIds().size()); + } + + @Test + void vehicleAtDestinationArchivesAndDeletesQueueExactlyOnce() { + GridMap map = simpleMap(); + InMemoryWcsState state = new InMemoryWcsState(); + state.addVehicle(vehicle("1", "TARGET")); + DispatchQueueEntry queue = DispatchQueueEntry.business(task(), "1"); + queue.markIssued(Arrays.asList("A", "TARGET")); + state.addQueue(queue); + Fixture fixture = fixture(map, state); + + fixture.center.runCycle(); + fixture.center.runCycle(); + + assertTrue(fixture.state.getActiveQueues().isEmpty()); + assertEquals(1, fixture.state.getHistory().size()); + assertEquals(4, fixture.state.getHistory().get(0).getExecutionStatus()); + } + + @Test + void channelConflictAddsAvoidanceQueueForBlockingVehicle() { + GridMap map = simpleMap(); + map.setCacheSite("TARGET-CHANNEL", "CACHE"); + InMemoryWcsState state = new InMemoryWcsState(); + state.addVehicle(vehicle("1", "A")); + state.addVehicle(vehicle("2", "BLOCKER")); + state.addTask(task()); + Fixture fixture = fixture(map, state); + + fixture.center.runCycle(); + + assertEquals(2, fixture.state.getActiveQueues().size()); + assertTrue(fixture.state.findQueueByVehicle("2").isPresent()); + assertEquals( + DispatchQueueEntry.QueueType.AVOIDANCE, + fixture.state.findQueueByVehicle("2").get().getQueueType() + ); + assertTrue(fixture.state.getEvents().toString().contains("AVOID")); + } + + private Fixture fixtureWithThreeVehicles() { + GridMap map = simpleMap(); + InMemoryWcsState state = new InMemoryWcsState(); + state.addVehicle(vehicle("3", "V3")); + state.addVehicle(vehicle("2", "V2")); + state.addVehicle(vehicle("1", "A")); + state.addTask(task()); + return fixture(map, state); + } + + private Fixture fixtureWithOneVehicle() { + GridMap map = simpleMap(); + InMemoryWcsState state = new InMemoryWcsState(); + state.addVehicle(vehicle("1", "A")); + state.addTask(task()); + return fixture(map, state); + } + + private Fixture fixture(GridMap map, InMemoryWcsState state) { + QueueAdmissionService admission = new QueueAdmissionService( + new VehicleAssignmentService(), + map + ); + SimulatedProtocolAdapter protocol = new SimulatedProtocolAdapter(); + LegacyDispatchCenter center = new LegacyDispatchCenter( + state, + admission, + new TrafficController(map, new GridPathPlanner()), + protocol + ); + return new Fixture(state, admission, protocol, center); + } + + private GridMap simpleMap() { + GridMap map = new GridMap(); + map.addSite(new Site("A", 1, 0, 0, "START")); + map.addSite(new Site("V2", 1, 0, 1, "V2-CHANNEL")); + map.addSite(new Site("V3", 1, 0, 2, "V3-CHANNEL")); + map.addSite(new Site("BLOCKER", 1, 1, 0, "TARGET-CHANNEL")); + map.addSite(new Site("TARGET", 1, 2, 0, "TARGET-CHANNEL")); + map.addSite(new Site("CACHE", 1, 1, 1, "CACHE")); + map.connect("A", "BLOCKER"); + map.connect("BLOCKER", "TARGET"); + map.connect("BLOCKER", "CACHE"); + map.connect("V2", "A"); + map.connect("V3", "V2"); + return map; + } + + private DispatchTask task() { + return new DispatchTask( + "TASK-1", + DispatchTask.TaskType.OUTBOUND, + "A", + "TARGET", + 1, + 2 + ); + } + + private VehicleSnapshot vehicle(String id, String site) { + return new VehicleSnapshot(id, 1, site, 5, 1, 80, true, true, false); + } + + private static final class Fixture { + private final InMemoryWcsState state; + private final QueueAdmissionService admission; + private final SimulatedProtocolAdapter protocol; + private final LegacyDispatchCenter center; + + private Fixture( + InMemoryWcsState state, + QueueAdmissionService admission, + SimulatedProtocolAdapter protocol, + LegacyDispatchCenter center + ) { + this.state = state; + this.admission = admission; + this.protocol = protocol; + this.center = center; + } + } +}