feat(wcs): reconcile device facts with execution state
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
package com.ruoyi.wcs.application.device;
|
||||
|
||||
import com.ruoyi.wcs.domain.execution.CommittedDeviceExecution;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceCommand;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceCommandState;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceCommandType;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceEvent;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceSnapshot;
|
||||
import com.ruoyi.wcs.port.out.DeviceCommandRepository;
|
||||
import com.ruoyi.wcs.port.out.DeviceEventRepository;
|
||||
import com.ruoyi.wcs.port.out.DeviceExecutionRepository;
|
||||
import com.ruoyi.wcs.port.out.RecoveryCaseOpener;
|
||||
import com.ruoyi.wcs.port.out.ResourceReservationRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class FeedbackReconcilerTest {
|
||||
private FakeCommandRepository commands;
|
||||
private FakeExecutionRepository executions;
|
||||
private FakeEventRepository events;
|
||||
private FakeReservationRepository reservations;
|
||||
private FakeRecoveryCaseOpener recovery;
|
||||
private FeedbackReconciler reconciler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
commands = new FakeCommandRepository();
|
||||
executions = new FakeExecutionRepository();
|
||||
events = new FakeEventRepository();
|
||||
reservations = new FakeReservationRepository();
|
||||
recovery = new FakeRecoveryCaseOpener();
|
||||
reconciler = new FeedbackReconciler(commands, executions, events,
|
||||
reservations, recovery);
|
||||
|
||||
DeviceCommand command = DeviceCommand.pending(
|
||||
"CMD-1", "CMD-KEY-1", "VEH-1", DeviceCommandType.MOVE_ROUTE,
|
||||
"{}", 1, at(0));
|
||||
command.markSent("SEND-1", at(1));
|
||||
commands.put(command);
|
||||
executions.put(execution());
|
||||
commands.resetUpdateCount();
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateEventDoesNotAdvanceTheCommandTwice() {
|
||||
DeviceEvent completed = new DeviceEvent(
|
||||
"E-1", "VEH-1", "CMD-KEY-1", 10L, at(10), at(11),
|
||||
DeviceEvent.Type.COMPLETED, "TASK-1", "C",
|
||||
Collections.singletonList("C"));
|
||||
|
||||
EventAcceptanceResult first = reconciler.accept(completed);
|
||||
EventAcceptanceResult duplicate = reconciler.accept(completed);
|
||||
|
||||
assertEquals(EventAcceptanceResult.APPLIED, first);
|
||||
assertEquals(EventAcceptanceResult.DUPLICATE, duplicate);
|
||||
assertEquals(1, commands.updateCount("CMD-1"));
|
||||
assertEquals(1, reservations.releaseCallCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskNumberMismatchCreatesRecoveryInsteadOfReleasing() {
|
||||
DeviceSnapshot snapshot = snapshot("WRONG-TASK", "B",
|
||||
Arrays.asList("B", "C"), 5L, false);
|
||||
|
||||
ReconcileResult result = reconciler.reconcile(snapshot);
|
||||
|
||||
assertEquals(ReconcileStatus.RECOVERY_REQUIRED, result.getStatus());
|
||||
assertEquals(0, reservations.releaseCallCount);
|
||||
assertEquals(Collections.singletonList("TASK_NUMBER_MISMATCH"),
|
||||
recovery.reasons);
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasesOnlyResourcesStrictlyPassedByMatchingProgress() {
|
||||
DeviceSnapshot snapshot = snapshot("TASK-1", "B",
|
||||
Arrays.asList("B", "C"), 5L, false);
|
||||
|
||||
ReconcileResult result = reconciler.reconcile(snapshot);
|
||||
|
||||
assertEquals(ReconcileStatus.ADVANCED, result.getStatus());
|
||||
assertEquals(Arrays.asList("NODE:A", "EDGE:A:B"),
|
||||
result.getReleasedResourceKeys());
|
||||
assertEquals(Arrays.asList("NODE:A", "EDGE:A:B"),
|
||||
reservations.releasedResources);
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleSnapshotIsIgnoredWithoutReleasingAgain() {
|
||||
reconciler.reconcile(snapshot("TASK-1", "B",
|
||||
Arrays.asList("B", "C"), 5L, false));
|
||||
|
||||
ReconcileResult stale = reconciler.reconcile(snapshot("TASK-1", "C",
|
||||
Collections.singletonList("C"), 4L, false));
|
||||
|
||||
assertEquals(ReconcileStatus.IGNORED_STALE, stale.getStatus());
|
||||
assertEquals(1, reservations.releaseCallCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownCommandUsesNonTerminalSnapshotForDiagnosticsOnly() {
|
||||
DeviceCommand command = commands.findByCommandKey("CMD-KEY-1");
|
||||
command.markUnknown("WRITE_RESULT_AMBIGUOUS", at(2));
|
||||
commands.save(command);
|
||||
commands.resetUpdateCount();
|
||||
|
||||
ReconcileResult result = reconciler.reconcile(snapshot("TASK-1", "B",
|
||||
Arrays.asList("B", "C"), 5L, false));
|
||||
|
||||
assertEquals(ReconcileStatus.CONSISTENT, result.getStatus());
|
||||
assertTrue(result.getReleasedResourceKeys().isEmpty());
|
||||
assertEquals(0, reservations.releaseCallCount);
|
||||
assertEquals(-1L, executions.findByCommandKey("CMD-KEY-1")
|
||||
.getLastDeviceSequence());
|
||||
assertEquals(DeviceCommandState.UNKNOWN, command.getState());
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalCommandCannotReleaseResourcesFromLateCompletion() {
|
||||
DeviceCommand command = commands.findByCommandKey("CMD-KEY-1");
|
||||
command.reject("PLC_REJECTED", at(2));
|
||||
commands.save(command);
|
||||
commands.resetUpdateCount();
|
||||
|
||||
ReconcileResult result = reconciler.reconcile(snapshot("TASK-1", "C",
|
||||
Collections.singletonList("C"), 5L, true));
|
||||
|
||||
assertEquals(ReconcileStatus.RECOVERY_REQUIRED, result.getStatus());
|
||||
assertEquals(0, reservations.releaseCallCount);
|
||||
assertEquals(-1L, executions.findByCommandKey("CMD-KEY-1")
|
||||
.getLastDeviceSequence());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgementDoesNotRequireRouteFacts() {
|
||||
DeviceEvent acknowledgement = new DeviceEvent(
|
||||
"ACK-1", "VEH-1", "CMD-KEY-1", 2L, at(2), at(2),
|
||||
DeviceEvent.Type.ACKNOWLEDGED, "TASK-1", null, null);
|
||||
|
||||
assertEquals(EventAcceptanceResult.APPLIED,
|
||||
reconciler.accept(acknowledgement));
|
||||
assertEquals(DeviceCommandState.ACKNOWLEDGED,
|
||||
commands.findByCommandKey("CMD-KEY-1").getState());
|
||||
assertEquals(0, reservations.releaseCallCount);
|
||||
}
|
||||
|
||||
private static CommittedDeviceExecution execution() {
|
||||
return new CommittedDeviceExecution(
|
||||
"CMD-1", "CMD-KEY-1", "VEH-1", "TASK-1",
|
||||
Arrays.asList("A", "B", "C"),
|
||||
Arrays.asList("NODE:A", "EDGE:A:B", "NODE:B",
|
||||
"EDGE:B:C", "NODE:C"),
|
||||
7L, 11L, "BOOT-1");
|
||||
}
|
||||
|
||||
private static DeviceSnapshot snapshot(String taskNumber, String currentNode,
|
||||
List<String> remaining, long sequence,
|
||||
boolean completed) {
|
||||
return new DeviceSnapshot("SNAP-" + sequence, "VEH-1",
|
||||
"CMD-KEY-1", taskNumber, sequence, at(sequence), true,
|
||||
"BOOT-1", 7L, 11L, currentNode, remaining, completed);
|
||||
}
|
||||
|
||||
private static Instant at(long seconds) {
|
||||
return Instant.EPOCH.plusSeconds(seconds);
|
||||
}
|
||||
|
||||
private static final class FakeCommandRepository
|
||||
implements DeviceCommandRepository {
|
||||
private final Map<String, DeviceCommand> byId =
|
||||
new HashMap<String, DeviceCommand>();
|
||||
private final Map<String, Integer> updates =
|
||||
new HashMap<String, Integer>();
|
||||
|
||||
void put(DeviceCommand command) {
|
||||
byId.put(command.getCommandId(), command);
|
||||
}
|
||||
|
||||
void resetUpdateCount() {
|
||||
updates.clear();
|
||||
}
|
||||
|
||||
int updateCount(String commandId) {
|
||||
Integer count = updates.get(commandId);
|
||||
return count == null ? 0 : count;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeviceCommand findByCommandKey(String commandKey) {
|
||||
for (DeviceCommand command : byId.values()) {
|
||||
if (command.getCommandKey().equals(commandKey)) {
|
||||
return command;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeviceCommand findByCommandId(String commandId) {
|
||||
return byId.get(commandId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(DeviceCommand command) {
|
||||
byId.put(command.getCommandId(), command);
|
||||
Integer count = updates.get(command.getCommandId());
|
||||
updates.put(command.getCommandId(), count == null ? 1 : count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeExecutionRepository
|
||||
implements DeviceExecutionRepository {
|
||||
private final Map<String, CommittedDeviceExecution> byCommand =
|
||||
new HashMap<String, CommittedDeviceExecution>();
|
||||
|
||||
void put(CommittedDeviceExecution execution) {
|
||||
byCommand.put(execution.getCommandKey(), execution);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommittedDeviceExecution findByCommandKey(String commandKey) {
|
||||
return byCommand.get(commandKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommittedDeviceExecution findActiveByDeviceId(String deviceId) {
|
||||
for (CommittedDeviceExecution execution : byCommand.values()) {
|
||||
if (execution.getDeviceId().equals(deviceId)) {
|
||||
return execution;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(CommittedDeviceExecution execution) {
|
||||
put(execution);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeEventRepository
|
||||
implements DeviceEventRepository {
|
||||
private final Set<String> ids = new HashSet<String>();
|
||||
|
||||
@Override
|
||||
public boolean claim(DeviceEvent event) {
|
||||
return ids.add(event.getEventId());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeReservationRepository
|
||||
implements ResourceReservationRepository {
|
||||
private int releaseCallCount;
|
||||
private List<String> releasedResources = Collections.emptyList();
|
||||
|
||||
@Override
|
||||
public void releasePassedResources(String commandKey,
|
||||
List<String> resourceKeys,
|
||||
String releaseEvidenceId,
|
||||
long fencingToken) {
|
||||
releaseCallCount++;
|
||||
releasedResources = new ArrayList<String>(resourceKeys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void protectForUnknown(String commandKey, String evidenceId) {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeRecoveryCaseOpener
|
||||
implements RecoveryCaseOpener {
|
||||
private final List<String> reasons = new ArrayList<String>();
|
||||
|
||||
@Override
|
||||
public void open(String deviceId, String commandKey, String reason,
|
||||
List<String> evidenceIds) {
|
||||
reasons.add(reason);
|
||||
assertTrue(!evidenceIds.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user