feat(wcs): reconcile device facts with execution state

This commit is contained in:
zgw
2026-08-11 10:24:59 +08:00
parent 0152fb1a81
commit f191d49319
17 changed files with 1170 additions and 0 deletions

View File

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