fix(shadow): repair field routing and harden evidence collection

This commit is contained in:
zgw
2026-09-07 12:42:17 +08:00
parent 9829b9c0fa
commit 414b3bf4a9
37 changed files with 1751 additions and 103 deletions

View File

@@ -68,6 +68,109 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class Ft546ShadowFaultIsolationTest {
private static final Instant NOW = Instant.parse("2026-08-14T06:00:00Z");
@Test
void emptyPollPublishesHeartbeatAndOrderlyCloseIsSticky() {
MutableClock clock = new MutableClock(NOW);
ShadowStartupReport report = report();
List<Map<String, Object>> heartbeats = new ArrayList<Map<String, Object>>();
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(emptySource(new AtomicInteger()), clock),
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
report, clock, Duration.ofSeconds(10), heartbeats::add);
try {
assertTrue(scheduler.runOnce());
assertEquals("STARTING", heartbeats.get(0).get("status"));
Map<String, Object> ready = heartbeats.get(heartbeats.size() - 1);
assertEquals("READY", ready.get("status"));
assertEquals(NOW.toString(), ready.get("lastAttemptAt"));
assertTrue(ready.get("lastCompletedCycle") != null);
assertEquals(Boolean.FALSE, ready.get("collectionStopped"));
} finally {
scheduler.close();
}
report.markReady(NOW.plusSeconds(5), 0L);
report.markPermanentlyDegraded("LATE_FAILURE", NOW.plusSeconds(6));
assertEquals(ShadowStartupReport.Status.STOPPED, report.getStatus());
Map<String, Object> stopped = heartbeats.get(heartbeats.size() - 1);
assertEquals("STOPPED", stopped.get("status"));
assertEquals(Boolean.TRUE, stopped.get("collectionStopped"));
assertFalse(scheduler.runOnce());
}
@Test
void runtimeWriteFailureStopsBeforeReadingSource() {
MutableClock clock = new MutableClock(NOW);
AtomicInteger calls = new AtomicInteger();
ShadowStartupReport report = report();
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(emptySource(calls), clock),
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
report, clock, Duration.ofSeconds(10), state -> { throw new IllegalStateException("offline"); })) {
assertFalse(scheduler.runOnce());
assertEquals(0, calls.get());
assertTrue(scheduler.isPermanentlyStopped());
assertEquals("EVIDENCE_RUNTIME_WRITE_FAILED", report.getReasonCode());
}
}
@Test
void retryAndPermanentFailurePublishDifferentCollectionStates() {
for (boolean timeout : new boolean[] {false, true}) {
MutableClock clock = new MutableClock(NOW);
AtomicInteger calls = new AtomicInteger();
ShadowStartupReport report = report();
List<Map<String, Object>> heartbeats = new ArrayList<Map<String, Object>>();
SqlServerShadowFactReader.FactSource source = new FactSourceAdapter() {
@Override
public Instant readDatabaseClock(Instant deadline) {
calls.incrementAndGet();
if (timeout) throw new QueryTimeoutException("timeout");
throw new DataAccessResourceFailureException("offline");
}
};
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(reader(source, clock),
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
report, clock, Duration.ofSeconds(10), heartbeats::add)) {
assertFalse(scheduler.runOnce());
Map<String, Object> failed = heartbeats.get(heartbeats.size() - 1);
assertEquals("DEGRADED", failed.get("status"));
assertEquals(Boolean.valueOf(timeout), failed.get("collectionStopped"));
assertEquals(null, failed.get("lastCompletedCycle"));
clock.advance(Duration.ofMillis(100));
scheduler.runScheduledCycle();
Map<String, Object> waiting = heartbeats.get(heartbeats.size() - 1);
assertEquals(clock.instant().toString(), waiting.get("heartbeatAt"));
assertEquals(NOW.toString(), waiting.get("lastAttemptAt"));
assertEquals(1, calls.get());
}
}
}
@Test
void capacityLimitPublishesSpecificPermanentReasonWithoutEvidenceWrites() {
for (String reason : Arrays.asList("EVIDENCE_DATABASE_SIZE_LIMIT", "EVIDENCE_BATCH_PAYLOAD_LIMIT")) {
MutableClock clock = new MutableClock(NOW);
ShadowStartupReport report = report();
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
if (reason.contains("PAYLOAD")) evidence.saveFailure = new ShadowEvidenceException(reason);
List<Map<String, Object>> heartbeats = new ArrayList<Map<String, Object>>();
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(emptySource(new AtomicInteger()), clock), evidenceCycle(report, evidence, gateway(),
() -> reason.contains("DATABASE") ? ShadowEvidenceStorageHealth.Check.unavailable(reason)
: ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
report, clock, Duration.ofSeconds(10), heartbeats::add)) {
assertTrue(scheduler.runOnce());
assertEquals(reason, report.getReasonCode());
assertTrue(scheduler.isPermanentlyStopped());
assertEquals(reason, heartbeats.get(heartbeats.size() - 1).get("reasonCode"));
if (reason.contains("DATABASE")) assertEquals(0, evidence.transactionCalls.get());
}
}
}
@Test
void sourceQueryTimeoutPermanentlyStopsCollectionBeforeCycle() {
MutableClock clock = new MutableClock(NOW);
@@ -89,6 +192,8 @@ class Ft546ShadowFaultIsolationTest {
try {
assertFalse(scheduler.runOnce());
assertEquals("SOURCE_READ", report.getCycleDiagnostics().get("phase"));
assertEquals("FAILED", report.getCycleDiagnostics().get("outcome"));
clock.advance(Duration.ofMinutes(1));
assertFalse(scheduler.runOnce());
@@ -364,6 +469,11 @@ class Ft546ShadowFaultIsolationTest {
event.getFormattedMessage()
.contains("stage=SHADOW_CYCLE")),
"timeout log must identify the blocked stage");
assertTrue(logAppender.list.stream().anyMatch(event ->
event.getFormattedMessage().contains("STORAGE_HEALTH")
&& event.getFormattedMessage().contains("elapsedMillis")
&& event.getFormattedMessage().contains("workerStack")),
"timeout must capture subphase, monotonic elapsed time and worker stack before cancellation");
assertFalse(scheduler.runOnce());
assertEquals(callsAfterTimeout, sourceCalls.get());
assertEquals(Collections.emptyList(), gateway.getRecorded());

View File

@@ -0,0 +1,16 @@
package com.ruoyi.shadow.config;
import com.ruoyi.wcs.domain.shared.IdGenerator;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
class ShadowIdGeneratorTest {
@Test
void separateApplicationRunsCannotReuseReservationIds() {
IdGenerator first = new ShadowKernelConfiguration().shadowIdGenerator();
IdGenerator restarted = new ShadowKernelConfiguration().shadowIdGenerator();
assertNotEquals(first.nextId(), restarted.nextId());
assertNotEquals(first.nextId(), first.nextId());
}
}

View File

@@ -80,6 +80,77 @@ class JdbcShadowEvidenceRepositoryTest {
objectMapper, run);
}
@Test
void heartbeatPersistsEmptyRunWithoutInventingCycleEvidence() throws Exception {
createTrafficSchema();
com.ruoyi.shadow.startup.ShadowStartupReport report =
com.ruoyi.shadow.startup.ShadowStartupReport.starting(NOW, Collections.emptyMap());
repository.saveRuntimeState(report.snapshotRuntimeState(NOW, null, false));
assertEquals(1, count("shadow_run"));
assertEquals(0, count("shadow_cycle"));
assertEquals(1, count("shadow_traffic_state"));
assertEquals("FT546:RUN:" + run.getRunId(), jdbc.queryForObject(
"SELECT state_key FROM shadow_traffic_state", String.class));
report.markReady(NOW.plusSeconds(5), 0L);
repository.saveRuntimeState(report.snapshotRuntimeState(NOW.plusSeconds(5), NOW, false));
assertEquals("RUNNING", jdbc.queryForObject("SELECT status FROM shadow_run", String.class));
com.fasterxml.jackson.databind.JsonNode state = objectMapper.readTree(jdbc.queryForObject(
"SELECT state_json FROM shadow_traffic_state", String.class));
assertEquals(NOW.plusSeconds(5).toString(), state.path("heartbeatAt").asText());
assertEquals(NOW.toString(), state.path("lastAttemptAt").asText());
assertEquals(NOW.plusSeconds(5).toString(), state.path("lastCompletedCycle").asText());
assertEquals(1, count("shadow_traffic_state"));
}
@Test
void stoppedRunCannotBeResurrectedAndRuntimeWritesAreAtomic() {
createTrafficSchema();
com.ruoyi.shadow.startup.ShadowStartupReport report =
com.ruoyi.shadow.startup.ShadowStartupReport.starting(NOW, Collections.emptyMap());
Map<String, Object> old = report.snapshotRuntimeState(NOW, null, false);
report.markStopped(NOW.plusSeconds(1));
repository.saveRuntimeState(report.snapshotRuntimeState(NOW.plusSeconds(1), null, true));
JdbcShadowEvidenceRepository restarted = new JdbcShadowEvidenceRepository(jdbc, transaction, objectMapper, run);
assertThrows(ShadowEvidenceException.class, () -> restarted.saveRuntimeState(old));
assertEquals("STOPPED", jdbc.queryForObject("SELECT status FROM shadow_run", String.class));
jdbc.execute("ALTER TABLE shadow_traffic_state ADD CONSTRAINT ck_runtime_write CHECK (snapshot_version < 2)");
assertThrows(RuntimeException.class, () -> repository.saveRuntimeState(
report.snapshotRuntimeState(NOW.plusSeconds(2), null, true)));
assertEquals(1L, jdbc.queryForObject("SELECT snapshot_version FROM shadow_traffic_state", Long.class));
}
@Test
void oversizedEvidenceRollsBackTrafficAndRunWithoutPartialRows() {
createTrafficSchema();
JdbcShadowEvidenceRepository bounded = new JdbcShadowEvidenceRepository(jdbc, transaction,
objectMapper, run, 1L);
JdbcShadowTrafficStateRepository traffic = new JdbcShadowTrafficStateRepository(jdbc, transaction,
objectMapper, java.time.Clock.fixed(NOW, ZoneOffset.UTC),
new ShadowCommandIntentFactory(), recordingGateway());
ShadowEvidenceException failure = assertThrows(ShadowEvidenceException.class,
() -> bounded.inTransaction(() -> {
assertTrue(traffic.commit(trafficCommit()));
return bounded.save(matchingCycle());
}));
assertEquals("EVIDENCE_BATCH_PAYLOAD_LIMIT", failure.getReasonCode());
assertEquals(0, count("shadow_run"));
assertEquals(0, count("shadow_cycle"));
assertEquals(0, count("shadow_traffic_state"));
assertEquals(0, count("shadow_hold_history"));
}
@Test
void payloadLimitDoesNotRejectAnAlreadyPersistedReplay() {
ShadowCycleEvidence evidence = matchingCycle();
repository.save(evidence);
JdbcShadowEvidenceRepository bounded = new JdbcShadowEvidenceRepository(jdbc, transaction,
objectMapper, run, 1L);
assertEquals(evidence.getCycleId(), bounded.save(evidence).getCycleId());
assertEquals(1, count("shadow_cycle"));
assertEquals(evidence.getInputJson(), jdbc.queryForObject(
"SELECT captured_json FROM shadow_input_batch", String.class));
}
@Test
void sqlServerDuplicateKeyCodesAreRecognizedWithoutBroadeningIntegrityErrors() {
assertTrue(JdbcShadowEvidenceRepository.isDuplicateKey(

View File

@@ -18,6 +18,28 @@ class JdbcShadowEvidenceStorageHealthTest {
@TempDir
Path outputDirectory;
@Test
void databaseAllocationAtLimitStopsCollection() {
JdbcShadowEvidenceStorageHealth health = new JdbcShadowEvidenceStorageHealth(
repository(true), outputDirectory, 0L, () -> 1000L, 1000L);
assertEquals("EVIDENCE_DATABASE_SIZE_LIMIT", health.verify().getReasonCode());
}
@Test
void databaseAllocationBelowLimitIsAvailable() {
assertTrue(new JdbcShadowEvidenceStorageHealth(repository(true), outputDirectory,
0L, () -> 999L, 1000L).verify().isAvailable());
}
@Test
void unreadableOrInvalidDatabaseSizeFailsClosed() {
assertEquals("EVIDENCE_DATABASE_SIZE_CHECK_FAILED", new JdbcShadowEvidenceStorageHealth(
repository(true), outputDirectory, 0L, () -> { throw new IllegalStateException(); },
1000L).verify().getReasonCode());
assertEquals("EVIDENCE_DATABASE_SIZE_CHECK_FAILED", new JdbcShadowEvidenceStorageHealth(
repository(true), outputDirectory, 0L, () -> -1L, 1000L).verify().getReasonCode());
}
@Test
void unavailableDatabaseFailsBeforeDiskCheck() {
Path missing = outputDirectory.resolve("missing");

View File

@@ -264,6 +264,27 @@ class SqlServerShadowFactReaderTest {
org.mockito.ArgumentMatchers.contains("dbo."));
}
@Test
void jdbcQueueProjectionCapturesSourceFloorFromExistingReadOnlyView() throws Exception {
DataSource dataSource = mock(DataSource.class);
Connection connection = mock(Connection.class);
PreparedStatement statement = mock(PreparedStatement.class);
ResultSet result = mock(ResultSet.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.prepareStatement(org.mockito.ArgumentMatchers.anyString())).thenReturn(statement);
when(statement.executeQuery()).thenReturn(result);
when(result.next()).thenReturn(true, false, false);
when(result.getString("guid")).thenReturn("LIFT");
when(result.getString("the_floor")).thenReturn("2");
SqlServerShadowFactReader.JdbcFactSource source = new SqlServerShadowFactReader.JdbcFactSource(
new JdbcTemplate(dataSource), Clock.fixed(NOW, ZoneOffset.UTC));
List<ShadowQueueFact> queues = source.readQueues(ShadowReadCursor.initial(), NOW.plusSeconds(4));
assertEquals(1, queues.size());
assertEquals("2", queues.get(0).getTheFloor());
assertTrue(SqlServerShadowFactReader.JdbcFactSource.QUEUE_SQL_PREFIX.contains("the_floor"));
verify(result).getString("the_floor");
}
@Test
void jdbcVehicleProjectionCapturesDispatchAndLegacyGateFacts()
throws Exception {

View File

@@ -254,6 +254,41 @@ class Ft546ShadowCycleTest {
assertEquals(now, report.getLastCompletedCycle());
}
@Test
void evidenceCompletionAndReadyTimeIncludeWorkInsteadOfReusingEntryTime() {
Instant now = Instant.parse("2026-09-06T23:59:59Z");
java.util.concurrent.atomic.AtomicReference<Instant> time =
new java.util.concurrent.atomic.AtomicReference<Instant>(now);
Clock clock = new Clock() {
public java.time.ZoneId getZone() { return ZoneOffset.UTC; }
public Clock withZone(java.time.ZoneId zone) { return this; }
public Instant instant() { return time.get(); }
};
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
java.util.concurrent.atomic.AtomicLong nanos = new java.util.concurrent.atomic.AtomicLong();
ShadowCycleDiagnostics progress = new ShadowCycleDiagnostics(nanos::get);
evidence.beforeCommit = () -> {
time.set(now.plusSeconds(3));
nanos.set(3000000000L);
};
ShadowStartupReport report = ShadowStartupReport.starting(now, Collections.emptyMap());
Ft546ShadowCycle cycle = evidenceCycle(now, evidence, report, gateway(), clock,
() -> {
time.set(now.plusSeconds(2));
nanos.set(2000000000L);
});
ShadowFactBatch batch = ShadowFactBatch.create(now.minusMillis(50), now,
Collections.singletonList(queueWithoutLegacyAssignment(now)),
Collections.singletonList(vehicle(now)), maps());
assertEquals("COMPARABLE", cycle.run(batch, now, progress).getStatusCode());
assertEquals(now.plusSeconds(2), evidence.saved.get(0).getCompletedAt());
assertEquals(now.plusSeconds(3), report.getLastCompletedCycle());
assertEquals(Long.valueOf(3000), report.getSourceLagMillis());
Map<?, ?> timings = (Map<?, ?>) progress.finish("COMPARABLE", null).get("phaseMillis");
assertEquals(1000L, timings.get("TRANSACTION_COMMIT"));
assertEquals(2000L, timings.get("DISPATCH_AND_STATE_SAVE"));
}
@Test
void evidenceFailureRecordsNoCommandAndStopsLaterCalculation() {
Instant now = Instant.parse("2026-08-14T06:00:00Z");
@@ -345,7 +380,7 @@ class Ft546ShadowCycleTest {
ShadowCycleResult second = cycle.run(batch, now.plusSeconds(1));
assertEquals("DEGRADED", first.getStatusCode());
assertEquals("EVIDENCE_STORAGE_UNAVAILABLE", first.getReasonCode());
assertEquals("FREE_SPACE_BELOW_MINIMUM", first.getReasonCode());
assertEquals("DEGRADED", second.getStatusCode());
assertEquals(1, healthChecks.get());
assertEquals(0, assemblyCalls.get());
@@ -357,6 +392,14 @@ class Ft546ShadowCycleTest {
Instant now, RecordingEvidenceRepository evidence,
ShadowStartupReport report,
RecordingFt546CommandGateway gateway) {
return evidenceCycle(now, evidence, report, gateway,
Clock.fixed(now, ZoneOffset.UTC), () -> { });
}
private static Ft546ShadowCycle evidenceCycle(
Instant now, RecordingEvidenceRepository evidence,
ShadowStartupReport report, RecordingFt546CommandGateway gateway,
Clock clock, Runnable onDispatch) {
RoutePlanner routePlanner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator(
new DefaultVehicleAssignmentPolicy(routePlanner,
@@ -365,6 +408,7 @@ class Ft546ShadowCycleTest {
commit -> {
evidence.dispatchObservedInsideTransaction =
evidence.insideTransaction;
onDispatch.run();
return true;
}, () -> "SHADOW-1");
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> readOnly =
@@ -384,7 +428,7 @@ class Ft546ShadowCycleTest {
assembler::assemble, readOnly, gateway,
() -> ShadowEvidenceStorageHealth.Check.available(
Long.MAX_VALUE),
evidence, collector(evidence.runId, 3L), report);
evidence, collector(evidence.runId, 3L), report, clock);
}
private static ShadowCycleEvidenceCollector collector(
@@ -542,6 +586,7 @@ class Ft546ShadowCycleTest {
private boolean insideTransaction;
private boolean dispatchObservedInsideTransaction;
private RuntimeException saveFailure;
private Runnable beforeCommit = () -> { };
@Override
public SavedCycle save(ShadowCycleEvidence evidence) {
@@ -570,7 +615,9 @@ class Ft546ShadowCycleTest {
transactionCalls.incrementAndGet();
insideTransaction = true;
try {
return work.get();
T result = work.get();
beforeCommit.run();
return result;
} finally {
insideTransaction = false;
}

View File

@@ -72,6 +72,38 @@ class JdbcShadowTrafficStateRepositoryTest {
topology = topology();
}
@Test
void hypotheticalGrantDoesNotBlockNextPollOrRestart() {
ResourceLedger ledger = new ResourceLedger(topology);
assertTrue(ledger.reserve(new ReservationRequest("PREDICTED", "ORDER-1", "STEP-1",
"VEH-1", Collections.singletonList("NODE:A"))).isGranted());
WaitForGraph graph = new WaitForGraph();
graph.addWait("VEH-2", "VEH-1");
assertTrue(repository().commit(predictionCommit(ledger, graph, "PREDICTED")));
ShadowTrafficState next = repository().load(topology);
assertEquals(1L, next.getSnapshotVersion());
assertTrue(next.getLedger().reserve(new ReservationRequest("NEXT-PREDICTION", "ORDER-2", "STEP-2",
"VEH-2", Collections.singletonList("NODE:A"))).isGranted());
assertTrue(next.getWaitForGraph().holdersWaitedOnBy("VEH-2").isEmpty());
assertEquals(1, next.getConsecutiveHoldCounts().values().iterator().next().intValue());
assertTrue(jdbc.queryForObject("SELECT state_json FROM shadow_traffic_state", String.class)
.contains("PREDICTED"));
}
@Test
void unknownOccupiedReservationRemainsProtectedAcrossPredictions() {
ResourceLedger ledger = new ResourceLedger(topology);
ReservationDecision reserved = ledger.reserve(new ReservationRequest("UNKNOWN", "ORDER-1", "STEP-1",
"VEH-1", Collections.singletonList("NODE:A")));
ledger.occupy("UNKNOWN", reserved.getFencingToken());
ledger.markCommandUnknown("UNKNOWN");
assertTrue(repository().commit(predictionCommit(ledger, waitGraph(), "UNKNOWN")));
ShadowTrafficState next = repository().load(topology);
assertTrue(next.getLedger().reservation("UNKNOWN").isCommandUnknown());
assertFalse(next.getLedger().reserve(new ReservationRequest("NEXT", "ORDER-2", "STEP-2",
"VEH-2", Collections.singletonList("NODE:A"))).isGranted());
}
@Test
void restartReloadPreservesReservationsWaitEdgesAndThreeHoldCount() {
JdbcShadowTrafficStateRepository first = repository();
@@ -249,6 +281,22 @@ class JdbcShadowTrafficStateRepositoryTest {
Collections.emptyList(), Collections.emptyList(), ledger, graph);
}
private DispatchCommit predictionCommit(ResourceLedger ledger, WaitForGraph graph, String reservationId) {
Map<String, String> parameters = new LinkedHashMap<String, String>();
parameters.put("parentTaskId", "ORDER-1");
parameters.put("phase", "LOADED_TO_PLACE");
parameters.put("routeVersion", "3");
parameters.put("taskNo", "30001");
parameters.put("taskType", "1");
GrantedDispatch grant = new GrantedDispatch("ORDER-1", "STEP-1", "VEH-1",
new RoutePlan(Arrays.asList("70001", "70002"), Collections.singletonList("NODE:A"),
1, 0, topology.version()), Collections.singletonList(reservationId),
topology.version(), 1L, parameters);
return new DispatchCommit("FT546", topology.version(), 0L,
Collections.singletonList(grant), Collections.singletonList(held("CAPACITY_CONFLICT")),
Collections.emptyList(), Collections.emptyList(), ledger, graph);
}
private ResourceLedger protectedLedger() {
ResourceLedger ledger = new ResourceLedger(topology);
ReservationDecision reserved = ledger.reserve(new ReservationRequest(

View File

@@ -0,0 +1,153 @@
package com.ruoyi.shadow.kernel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.*;
import com.ruoyi.shadow.facts.*;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.ShadowDispatchResult;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
import com.ruoyi.wcs.port.out.DispatchPolicy;
import com.ruoyi.wcs.domain.traffic.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import static org.junit.jupiter.api.Assertions.*;
/** Independent input replay, not a reconstruction of historical traffic state. */
@EnabledIfSystemProperty(named = "shadow.replay.zip", matches = ".+")
class ShadowCapturedInputReplayTest {
@Test
void capturedInputsRetainSafetyGatesAndReconstructLegacyChargingCommands() throws Exception {
ObjectMapper mapper = new ObjectMapper();
int inputs = 0, legacyAvailable = 0, grants = 0, holds = 0, writes = 0;
Map<String, Integer> holdReasons = new TreeMap<String, Integer>();
try (ZipFile zip = new ZipFile(System.getProperty("shadow.replay.zip"))) {
ZipEntry entry = Collections.list(zip.entries()).stream()
.filter(e -> e.getName().replace('\\', '/').endsWith("/13_tail300_input.jsonl"))
.findFirst().orElseThrow(() -> new AssertionError("missing captured inputs"));
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
zip.getInputStream(entry), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.trim().isEmpty()) continue;
JsonNode row = mapper.readTree(line.replace("\uFEFF", ""));
String raw = row.get("captured_json").asText();
assertEquals(row.get("content_hash").asText().toLowerCase(Locale.ROOT), sha256(raw));
ShadowFactBatch batch = batch(mapper.readTree(raw));
Instant now = batch.getDatabaseCapturedAt();
ShadowFactValidation validation = new ShadowFactBatchValidator(
Duration.ofSeconds(4), Duration.ofSeconds(30)).validate(batch, now);
assertEquals(ShadowFactValidation.Status.COMPARABLE, validation.getStatus(),
row.get("cycle_id") + ": " + validation.getReasonCode());
for (ShadowQueueFact queue : batch.getQueues()) {
ShadowLegacyQueueResult legacy = new LegacyQueueProtocolProjector().project(queue);
assertEquals(ShadowLegacyQueueResult.Status.AVAILABLE, legacy.getStatus());
legacyAvailable++;
}
ShadowDispatchSnapshotAssembler assembler = new ShadowDispatchSnapshotAssembler(
"FT546", 1L, topology -> new ShadowTrafficState(new ResourceLedger(topology),
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(now, ZoneOffset.UTC));
DispatchSnapshot snapshot = assembler.assemble(batch);
RoutePlanner planner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator(
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
new DeadlockGuard(new WaitForGraph(), planner), commit -> true,
() -> UUID.randomUUID().toString());
ShadowDispatchResult<Ft546SrgvCommandIntent> result =
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(coordinator::dispatch,
new ShadowCommandIntentFactory()::create).run(snapshot);
grants += result.getDecisions().getGranted().size();
holds += result.getDecisions().getHeld().size();
result.getDecisions().getHeld().forEach(h ->
holdReasons.merge(h.getReasonCode(), 1, Integer::sum));
for (Ft546SrgvCommandIntent intent : result.getIntents()) {
writes += gateway().previewAll(Collections.singletonList(intent)).get(0).getWrites().size();
}
inputs++;
}
}
}
assertEquals(300, inputs);
assertEquals(600, legacyAvailable);
System.out.println("CAPTURED_REPLAY independentEmptyTrafficState inputs=" + inputs
+ " legacyAvailable=" + legacyAvailable + " grants=" + grants
+ " holds=" + holds + " encodedWrites=" + writes + " holdReasons=" + holdReasons);
}
static ShadowFactBatch batch(JsonNode input) {
List<ShadowQueueFact> queues = new ArrayList<ShadowQueueFact>();
for (JsonNode q : input.get("queues")) {
queues.add(ShadowQueueFact.builder(text(q, "tableName"), text(q, "guid"))
.taskNo(text(q, "taskNo")).taskType(text(q, "taskType"))
.priority(q.path("priority").isNumber() ? q.get("priority").longValue() : null)
.cargoPickSite(text(q, "cargoPickSite")).cargoPlaceSite(text(q, "cargoPlaceSite"))
.startSite(text(q, "startSite")).endSite(text(q, "endSite")).theFloor(text(q, "theFloor"))
.createdAt(instant(q, "createdAt")).agvId(text(q, "agvId"))
.pathInfo(text(q, "pathInfo")).turnPath(text(q, "turnPath"))
.default02(text(q, "default02")).default03(text(q, "default03"))
.default04(text(q, "default04")).default05(text(q, "default05"))
.default06(text(q, "default06")).default07(text(q, "default07"))
.default08(text(q, "default08")).build());
}
List<ShadowVehicleFact> vehicles = new ArrayList<ShadowVehicleFact>();
for (JsonNode v : input.get("vehicles")) {
vehicles.add(ShadowVehicleFact.builder(text(v, "guid"), text(v, "deviceId"))
.agvStatus(text(v, "agvStatus")).taskNo(number(v, "taskNo"))
.sourceSite(text(v, "sourceSite")).targetSite(text(v, "targetSite"))
.currentSite(text(v, "currentSite")).taskType(number(v, "taskType"))
.battery(number(v, "battery")).workMode(number(v, "workMode"))
.alarm(number(v, "alarm")).status(number(v, "status")).finish(number(v, "finish"))
.chargeFlag(number(v, "chargeFlag")).workPhase(number(v, "workPhase"))
.errorCode(number(v, "errorCode")).changeLayer(number(v, "changeLayer"))
.cargoFlag(number(v, "cargoFlag")).path(text(v, "path")).allowSend(number(v, "allowSend"))
.currentFloor(number(v, "currentFloor")).locked(number(v, "locked"))
.spare2(text(v, "spare2")).spare5(text(v, "spare5"))
.capturedAt(instant(v, "capturedAt")).build());
}
List<ShadowMapFact> maps = new ArrayList<ShadowMapFact>();
for (JsonNode m : input.get("maps")) {
maps.add(new ShadowMapFact(text(m, "guid"), text(m, "siteId"), m.get("x").intValue(),
m.get("y").intValue(), m.get("z").intValue(), m.get("value").intValue()));
}
return ShadowFactBatch.create(instant(input, "databaseCaptureStartedAt"),
instant(input, "databaseCapturedAt"), queues, vehicles, maps);
}
private static String text(JsonNode row, String key) {
return row.path(key).isNull() || row.path(key).isMissingNode() ? null : row.get(key).asText();
}
private static Integer number(JsonNode row, String key) {
return text(row, key) == null ? null : Integer.valueOf(text(row, key));
}
private static Instant instant(JsonNode row, String key) {
return text(row, key) == null ? null : Instant.parse(text(row, key));
}
private static String sha256(String value) throws Exception {
StringBuilder hex = new StringBuilder();
for (byte b : MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))) {
hex.append(String.format(Locale.ROOT, "%02x", b & 255));
}
return hex.toString();
}
private static RecordingFt546CommandGateway gateway() {
Ft546DeviceProfile profile = new Ft546DeviceProfile();
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000"); registers.put("taskType", "4002");
registers.put("path", "4004"); registers.put("trigger", "4021");
profile.setRegisters(registers);
return new RecordingFt546CommandGateway(new Ft546SrgvCommandEncoder(), profile,
Collections.singleton("POINT_TO_POINT_COMMAND"));
}
}

View File

@@ -0,0 +1,40 @@
package com.ruoyi.shadow.kernel;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
class ShadowCycleDiagnosticsTest {
@Test
void monotonicDurationsIncludeQueueReadComputeAndCommit() {
AtomicLong nanos = new AtomicLong();
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics(nanos::get);
nanos.set(2000000L);
diagnostics.enter("SOURCE_READ");
nanos.set(7000000L);
diagnostics.enter("DISPATCH_AND_STATE_SAVE");
nanos.set(17000000L);
diagnostics.enter("TRANSACTION_COMMIT");
nanos.set(23000000L);
Map<String, Object> result = diagnostics.finish("COMPARABLE", null);
assertEquals(23L, result.get("elapsedMillis"));
Map<?, ?> phases = (Map<?, ?>) result.get("phaseMillis");
assertEquals(2L, phases.get("QUEUED"));
assertEquals(5L, phases.get("SOURCE_READ"));
assertEquals(10L, phases.get("DISPATCH_AND_STATE_SAVE"));
assertEquals(6L, phases.get("TRANSACTION_COMMIT"));
}
@Test
void timeoutSnapshotIsFrozenAndLateWorkerCannotEnterPersistence() {
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics();
diagnostics.enter("DISPATCH_AND_STATE_SAVE");
Map<String, Object> timeout = diagnostics.cancel("TIMEOUT", Thread.currentThread());
assertEquals("DISPATCH_AND_STATE_SAVE", timeout.get("phase"));
assertNotNull(timeout.get("workerStack"));
assertThrows(CancellationException.class, () -> diagnostics.enter("EVIDENCE_SAVE"));
assertSame(timeout, diagnostics.finish("COMPARABLE", null));
}
}

View File

@@ -0,0 +1,262 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.shadow.facts.LegacyQueueProtocolProjector;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
import com.ruoyi.shadow.facts.ShadowFactValidation;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult;
import com.ruoyi.shadow.facts.ShadowMapFact;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowVehicleFact;
import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.domain.traffic.RoutePlan;
import com.ruoyi.wcs.domain.dispatch.DispatchTask;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner;
import com.ruoyi.wcs.domain.traffic.DeadlockGuard;
import com.ruoyi.wcs.port.out.DispatchPolicy;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashMap;
import static org.junit.jupiter.api.Assertions.*;
class ShadowFieldRegressionTest {
private static final Instant NOW = Instant.parse("2026-09-06T18:41:57Z");
private static final String ACTIVE = "shadow_ro.v_wcs_srgv_queue";
private static final String FINISHED = "shadow_ro.v_wcs_srgv_queue_finish";
@Test
void initializedAgvFlagDoesNotHideAnAutomaticIdleSendableVehicle() {
VehicleSnapshot vehicle = assemble(vehicle().agvStatus("0").build());
assertTrue(vehicle.isDispatchable(31));
}
@Test
void plcSafetyGatesStillExcludeUnsafeOrIncompleteVehicles() {
List<ShadowVehicleFact> blocked = Arrays.asList(
vehicle().workMode(4).build(), vehicle().status(2).build(),
vehicle().allowSend(0).build(), vehicle().allowSend(null).build(),
vehicle().alarm(1).build(), vehicle().errorCode(1).build(),
vehicle().battery(30).build());
for (ShadowVehicleFact fact : blocked) {
assertFalse(assemble(fact).isDispatchable(31));
}
}
@Test
void singleSiteChargingQueueReconstructsBothLegacyEndpoints() {
ShadowQueueFact queue = queue(ACTIVE, "CHARGE", "080202", "080202")
.taskType("3").default02("3").pathInfo("080202,").build();
ShadowLegacyQueueResult legacy = new LegacyQueueProtocolProjector().project(queue);
assertEquals(ShadowLegacyQueueResult.Status.AVAILABLE, legacy.getStatus());
assertEquals(Arrays.asList(80202, 80202, 0, 0, 0, 0, 0, 0),
legacy.getWrites().get(0).getValues());
assertEquals(Arrays.asList(80202, 80202), legacy.getPathPoints());
assertEquals(4, legacy.getWrites().size());
}
@Test
void singleNodeCannotInventAMissingTransportRouteOrChargingDestination() {
LegacyQueueProtocolProjector projector = new LegacyQueueProtocolProjector();
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
projector.project(queue(ACTIVE, "MOVE", "080202", "080203")
.pathInfo("080202,").build()).getStatus());
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
projector.project(queue(ACTIVE, "CHARGE", "080202", "080203")
.taskType("3").default02("3").pathInfo("080202,")
.build()).getStatus());
}
@Test
void historicalVersionsStayInEvidenceWithoutInvalidatingLiveDispatchFacts() {
ShadowQueueFact active = queue(ACTIVE, "LIVE", "080202", "080203").build();
ShadowQueueFact old = queue(FINISHED, "HISTORY", "080202", "tsj").build();
ShadowQueueFact later = queue(FINISHED, "HISTORY", "tsj", "080203").build();
ShadowFactBatch batch = batch(Arrays.asList(old, active, later), vehicle().build());
assertEquals(ShadowFactValidation.Status.COMPARABLE,
validator().validate(batch, NOW).getStatus());
assertEquals(3, batch.getSourceQueues().size());
}
@Test
void activeIdentityConflictStillFailsClosed() {
ShadowFactBatch batch = batch(Arrays.asList(
queue(ACTIVE, "LIVE", "080202", "080203").build(),
queue(ACTIVE, "LIVE", "080203", "080202").build()), vehicle().build());
assertEquals(ShadowFactValidation.Status.INSUFFICIENT_FACTS,
validator().validate(batch, NOW).getStatus());
}
@Test
void lookupUsesTheActiveQueueWhenHistoryReusesItsGuid() {
ShadowQueueFact active = queue(ACTIVE, "SAME", "080202", "080203").build();
ShadowQueueFact history = queue(FINISHED, "SAME", "080203", "080202")
.createdAt(NOW.minusSeconds(20)).build();
assertSame(active, batch(Arrays.asList(history, active), vehicle().build()).queue("SAME"));
}
@Test
void liftEndUsesSourceFloorAndPreservesRawFacts() {
for (String type : Arrays.asList("8", "1", "2")) {
ShadowQueueFact queue = queue(ACTIVE, "LIFT", "080202", "tsj")
.cargoPlaceSite("080203").theFloor("2")
.default02(type).default07("1").build();
ShadowFactBatch batch = liftBatch(queue);
assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus());
DispatchTask task = assembler().assemble(batch).getReadySteps().get(0);
assertEquals("8".equals(type) ? "070502" : "090502", task.getDestinationNodeId());
assertEquals("tsj", batch.queue("LIFT").getEndSite());
}
}
@Test
void liftStartUsesAssignedVehicleCurrentSiteOnlyForKnownHandoff() {
ShadowQueueFact queue = queue(ACTIVE, "HANDOFF", "tsj", "080203")
.cargoPickSite("080202").theFloor("2").default02("4").build();
ShadowFactBatch batch = liftBatch(queue);
assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus());
assertEquals("080202", assembler().assemble(batch).getReadySteps().get(0).getSourceNodeId());
}
@Test
void missingFloorUnknownLiftTypeAndCargoPlaceholderStayInsufficient() {
List<ShadowQueueFact> invalid = Arrays.asList(
queue(ACTIVE, "LIFT", "080202", "tsj").cargoPlaceSite("080203").default02("8").build(),
queue(ACTIVE, "LIFT", "080202", "tsj").cargoPlaceSite("080203").theFloor("2").default02("7").build(),
queue(ACTIVE, "LIFT", "080202", "tsj").theFloor("2").default02("8").build(),
queue(ACTIVE, "HANDOFF", "tsj", "080203").cargoPickSite("080202").theFloor("2").default02("4").agvId(null).build(),
queue(ACTIVE, "HANDOFF", "tsj", "080203").cargoPickSite("080202").theFloor("1").default02("4").build());
for (ShadowQueueFact queue : invalid) {
assertEquals(ShadowFactValidation.Status.INSUFFICIENT_FACTS, validator().validate(liftBatch(queue), NOW).getStatus());
}
}
@Test
void singleNodeChargingGrantGeneratesTwoProtocolEndpoints() {
assertEquals(Arrays.asList(80202, 80202), new ShadowCommandIntentFactory()
.create(singletonGrant("3", null)).getPathPoints());
assertThrows(IllegalArgumentException.class, () -> new ShadowCommandIntentFactory()
.create(singletonGrant("1", null)));
assertThrows(IllegalArgumentException.class, () -> new ShadowCommandIntentFactory()
.create(singletonGrant("3", "1")));
}
@Test
void sameSiteChargeRunsThroughRealSelectionRoutingAndEncoding() {
AStarRoutePlanner planner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator(
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
new DeadlockGuard(new WaitForGraph(), planner), commit -> true, () -> "CHARGE-RES");
Ft546DeviceProfile profile = new Ft546DeviceProfile();
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000"); registers.put("taskType", "4002");
registers.put("path", "4004"); registers.put("trigger", "4021");
profile.setRegisters(registers);
Ft546ShadowCycle cycle = new Ft546ShadowCycle(validator(), assembler()::assemble,
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(coordinator::dispatch,
new ShadowCommandIntentFactory()::create),
new RecordingFt546CommandGateway(new Ft546SrgvCommandEncoder(), profile,
Collections.singleton("POINT_TO_POINT_COMMAND")));
ShadowQueueFact charge = queue(ACTIVE, "CHARGE", "080202", "080202")
.taskType("3").default02("3").pathInfo("080202,").build();
ShadowCycleResult result = cycle.run(batch(Collections.singletonList(charge), vehicle().build()), NOW);
assertEquals("COMPARABLE", result.getStatusCode(), result.getReasonCode());
assertEquals(1, result.getDecisions().getGranted().size());
assertEquals(Arrays.asList(80202, 80202), result.getIntents().get(0).getPathPoints());
assertEquals(4, result.getEncodedCommands().get(0).getWrites().size());
}
@Test
void sourceFloorParticipatesInHashAndRawEvidence() throws Exception {
ShadowQueueFact second = queue(ACTIVE, "LIFT", "080202", "tsj").theFloor("2").build();
ShadowQueueFact third = queue(ACTIVE, "LIFT", "080202", "tsj").theFloor("3").build();
assertNotEquals(second.getContentHash(), third.getContentHash());
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
String raw = new ShadowCycleEvidenceCollector(java.util.UUID.randomUUID(), 1L, mapper)
.insufficient(liftBatch(second), "TEST", NOW).get(0).getInputJson();
assertEquals("2", mapper.readTree(raw).get("queues").get(0).get("theFloor").asText());
assertEquals("tsj", mapper.readTree(raw).get("queues").get(0).get("endSite").asText());
}
private static GrantedDispatch singletonGrant(String type, String stage) {
Map<String, String> command = new LinkedHashMap<String, String>();
command.put("parentTaskId", "502");
command.put("phase", "LEGACY_PHASE_3");
command.put("routeVersion", "1");
command.put("taskNo", "502");
command.put("taskType", type);
if (stage != null) command.put("stage", stage);
return new GrantedDispatch("502", "CHARGE", "2",
new RoutePlan(Collections.singletonList("080202"),
Collections.singletonList("NODE:080202"), 0, 0, 1L),
Collections.singletonList("RES-1"), 1L, 1L, command);
}
private static ShadowFactBatch liftBatch(ShadowQueueFact queue) {
return ShadowFactBatch.create(NOW.minusMillis(50), NOW, Collections.singletonList(queue),
Collections.singletonList(vehicle().build()), Arrays.asList(
new ShadowMapFact("M1", "080202", 0, 0, 2, 1),
new ShadowMapFact("M2", "080203", 1, 0, 2, 1),
new ShadowMapFact("M3", "070502", 2, 0, 2, 1),
new ShadowMapFact("M4", "090502", 3, 0, 2, 1)));
}
private static ShadowDispatchSnapshotAssembler assembler() {
return new ShadowDispatchSnapshotAssembler("FT546", 1L,
topology -> new ShadowTrafficState(new ResourceLedger(topology),
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(NOW, ZoneOffset.UTC));
}
private static VehicleSnapshot assemble(ShadowVehicleFact vehicle) {
ShadowDispatchSnapshotAssembler assembler = new ShadowDispatchSnapshotAssembler(
"FT546", 1L, topology -> new ShadowTrafficState(new ResourceLedger(topology),
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(NOW, ZoneOffset.UTC));
return assembler.assemble(batch(Collections.emptyList(), vehicle)).getVehicleSnapshots().get(0);
}
private static ShadowFactBatch batch(List<ShadowQueueFact> queues, ShadowVehicleFact vehicle) {
return ShadowFactBatch.create(NOW.minusMillis(50), NOW, queues,
Collections.singletonList(vehicle), Arrays.asList(
new ShadowMapFact("M1", "080202", 0, 0, 2, 1),
new ShadowMapFact("M2", "080203", 1, 0, 2, 1)));
}
private static ShadowFactBatchValidator validator() {
return new ShadowFactBatchValidator(Duration.ofSeconds(4), Duration.ofSeconds(30));
}
private static ShadowQueueFact.Builder queue(String table, String guid, String start, String end) {
return ShadowQueueFact.builder(table, guid).taskNo("502").taskType("2").priority(2L)
.startSite(start).endSite(end).cargoPickSite(start).cargoPlaceSite(end)
.createdAt(NOW.minusSeconds(10)).agvId("2").pathInfo(start + "," + end + ",")
.turnPath("").default02("1");
}
private static ShadowVehicleFact.Builder vehicle() {
return ShadowVehicleFact.builder("2", "2").agvStatus("0").taskNo(502)
.currentSite("080202").sourceSite("080202").targetSite("080202")
.taskType(3).battery(80).workMode(5).alarm(0).status(1)
.finish(0).chargeFlag(0).workPhase(0).errorCode(0)
.changeLayer(0).cargoFlag(0).allowSend(1).currentFloor(2)
.locked(0).spare2("0").spare5("0").capturedAt(NOW.minusSeconds(7200));
}
}

View File

@@ -0,0 +1,153 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.config.ShadowKernelConfiguration;
import com.ruoyi.shadow.facts.*;
import com.ruoyi.wcs.application.dispatch.*;
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
import com.ruoyi.wcs.domain.traffic.*;
import com.ruoyi.wcs.port.out.DispatchPolicy;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class ShadowLivePathRegressionTest {
private static final Instant NOW = Instant.parse("2026-09-07T03:51:00Z");
private static final String FIELD_PATH = "090501,TD002,080501,070501,060501,WY0006,"
+ "060401,WY0004,060301,WY0002,060201,WY0001,060101,070101,";
@Test
void completedFieldPathReconstructsLiteralLegacyRegisterValues() {
ShadowQueueFact queue = queue("090501", "070101")
.pathInfo(FIELD_PATH).turnPath("060501,060101,0,0,0,0").build();
ShadowLegacyQueueResult result = new LegacyQueueProtocolProjector().project(queue);
assertEquals(ShadowLegacyQueueResult.Status.AVAILABLE, result.getStatus(), result.getReasonCode());
assertEquals(Arrays.asList(90501, 70101, 60501, 60101, 0, 0, 0, 0),
result.write("4004").getValues());
assertEquals("4004,4000,4002,4021", result.getWriteAddresses());
}
@Test
void plannedFieldPathKeepsVirtualResourcesButEncodesOnlyEndpointsAndTurns() {
DispatchSnapshot snapshot = snapshot(fieldMap(), "090501", "090501", "070101");
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
"090501", "070101", Collections.emptySet());
assertEquals(Arrays.asList(FIELD_PATH.substring(0, FIELD_PATH.length() - 1).split(",")),
route.getNodeIds());
assertTrue(route.getResourceKeys().contains("NODE:TD002"));
assertTrue(route.getResourceKeys().contains("NODE:WY0006"));
Ft546SrgvCommandIntent intent = new ShadowCommandIntentFactory().create(grant(route));
assertEquals(Arrays.asList(90501, 70101, 60501, 60101), intent.getPathPoints());
assertEquals(Arrays.asList(90501, 70101, 60501, 60101, 0, 0, 0, 0),
new ShadowKernelConfiguration().recordingFt546CommandGateway()
.preview(intent).getWrites().get(0).getValues());
}
@Test
void longStraightNumericPathHasTwoCommandPointsAndNoIntermediateDestination() {
List<ShadowMapFact> maps = new ArrayList<>();
for (int i = 1; i <= 12; i++) maps.add(map(String.format(Locale.ROOT, "%02d0101", i), i, 0));
DispatchSnapshot snapshot = snapshot(maps, "010101", "010101", "120101");
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
"010101", "120101", Collections.emptySet());
assertEquals(Arrays.asList(10101, 120101),
new ShadowCommandIntentFactory().create(grant(route)).getPathPoints());
}
@Test
void overlappingApproachAndExecutionReserveEachResourceOnceWithoutLosingRouteOrder() {
DispatchSnapshot snapshot = snapshot(fieldMap(), "060301", "070101", "060201");
AStarRoutePlanner planner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator(
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
new DeadlockGuard(new WaitForGraph(), planner), commit -> true,
() -> UUID.randomUUID().toString());
DispatchCycleResult result = coordinator.dispatch(snapshot);
assertEquals(1, result.getGranted().size());
RoutePlan route = result.getGranted().get(0).getProtectedRoute();
assertEquals(Arrays.asList("060301", "WY0002", "060201", "WY0001", "060101",
"070101", "060101", "WY0001", "060201"), route.getNodeIds());
assertEquals(new HashSet<>(route.getResourceKeys()).size(), route.getResourceKeys().size());
assertEquals(Arrays.asList(60301, 60201, 60101, 70101, 60101),
new ShadowCommandIntentFactory().create(result.getGranted().get(0)).getPathPoints());
}
@Test
void invalidHistoricalTransitEndpointsAndTurnsRemainUnavailable() {
for (String path : Arrays.asList("TD002,080501,", "080501,TD002,",
"080501,UNKNOWN,070501,", "080501,-1,070501,")) {
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
new LegacyQueueProtocolProjector().project(queue("080501", "070501")
.pathInfo(path).build()).getStatus());
}
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
new LegacyQueueProtocolProjector().project(queue("090501", "070101")
.pathInfo(FIELD_PATH).turnPath("TD002,060101,0,0,0,0").build()).getStatus());
}
@Test
void virtualTurningPointCannotBeSilentlyDroppedFromCommand() {
DispatchSnapshot snapshot = snapshot(Arrays.asList(map("010101", 0, 0),
map("WY0001", 1, 0), map("020201", 1, 1)), "010101", "010101", "020201");
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
"010101", "020201", Collections.emptySet());
assertThrows(IllegalArgumentException.class,
() -> new ShadowCommandIntentFactory().create(grant(route)));
}
@Test
void moreThanSixTurnsCannotBeTruncatedToFitRegisters() {
List<ShadowMapFact> maps = new ArrayList<>();
for (int i = 0; i < 10; i++) maps.add(map(String.format(Locale.ROOT, "%06d", 10101 + i),
(i + 1) / 2, i / 2));
DispatchSnapshot snapshot = snapshot(maps, "010101", "010101", "010110");
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
"010101", "010110", Collections.emptySet());
assertThrows(IllegalArgumentException.class,
() -> new ShadowCommandIntentFactory().create(grant(route)));
}
private static GrantedDispatch grant(RoutePlan route) {
Map<String, String> command = new LinkedHashMap<>();
command.put("parentTaskId", "31312"); command.put("phase", "EMPTY_TO_PICKUP");
command.put("routeVersion", "1"); command.put("taskNo", "31312"); command.put("taskType", "4");
return new GrantedDispatch("31312", "FIELD", "3", route,
Collections.singletonList("RES"), 1L, 1L, command);
}
private static ShadowQueueFact.Builder queue(String start, String end) {
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "FIELD")
.taskNo("31312").taskType("1").priority(2L).startSite(start).endSite(end)
.cargoPickSite(start).cargoPlaceSite(end).theFloor("1").agvId("3")
.createdAt(NOW.minusSeconds(10)).default02("4").default08("").turnPath("");
}
private static DispatchSnapshot snapshot(List<ShadowMapFact> maps, String location,
String source, String destination) {
ShadowVehicleFact vehicle = ShadowVehicleFact.builder("3", "3").agvStatus("0")
.currentSite(location).currentFloor(1).workMode(5).status(1).allowSend(1)
.alarm(0).errorCode(0).battery(80).cargoFlag(0).capturedAt(NOW).build();
ShadowFactBatch batch = ShadowFactBatch.create(NOW, NOW,
Collections.singletonList(queue(source, destination).build()),
Collections.singletonList(vehicle), maps);
return new ShadowDispatchSnapshotAssembler("FT546", 1L,
topology -> new ShadowTrafficState(new ResourceLedger(topology),
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(NOW, ZoneOffset.UTC))
.assemble(batch);
}
private static ShadowMapFact map(String site, int x, int y) {
return new ShadowMapFact(site, site, x, y, 1, 1);
}
private static List<ShadowMapFact> fieldMap() {
return Arrays.asList(map("090501", 11, 10), map("TD002", 11, 9),
map("080501", 11, 8), map("070501", 11, 7), map("060501", 11, 6),
map("WY0006", 10, 6), map("060401", 9, 6), map("WY0004", 8, 6),
map("060301", 7, 6), map("WY0002", 6, 6), map("060201", 5, 6),
map("WY0001", 4, 6), map("060101", 3, 6), map("070101", 3, 7));
}
}