fix(shadow): respect queue phases and compress input evidence

This commit is contained in:
zgw
2026-09-08 11:23:50 +08:00
parent 414b3bf4a9
commit af8b5f1830
40 changed files with 1035 additions and 46 deletions

View File

@@ -116,6 +116,7 @@ class Ft546ShadowApplicationTest {
context.getBean(com.ruoyi.wcs.port.out
.TrafficStateRepository.class));
assertNotNull(context.getBean(ShadowStatusController.class));
assertNotNull(context.getBean(com.ruoyi.shadow.web.ShadowInputController.class));
Map<String, String> persisted = context.getBean(
RecordingShadowDatabaseGate.class).getPersistedChecks();
assertNotNull(persisted);
@@ -138,11 +139,19 @@ class Ft546ShadowApplicationTest {
.andExpect(jsonPath("$.status").value("STARTING"))
.andExpect(jsonPath("$.checkedAt").exists())
.andExpect(jsonPath("$.evidenceBacklog").value(0))
.andExpect(jsonPath("$.runtimeIdentity.buildId").value("ft546-shadow-20260908-phase-storage-v2"))
.andExpect(jsonPath("$.runtimeIdentity.effectiveSettingsSha256").exists())
.andExpect(jsonPath("$.runtimeIdentity.safePointConfiguration").value("UNCONFIGURED"))
.andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED")
.value("PASS"))
.andExpect(jsonPath(
"$.startupChecks.SIDE_EFFECT_BEANS_ABSENT")
.value("PASS"));
mvc.perform(get("/shadow/inputs/00000000-0000-0000-0000-000000000001")
.with(request -> { request.setRemoteAddr("10.6.22.6"); return request; }))
.andExpect(status().isForbidden());
mvc.perform(get("/shadow/inputs/not-a-uuid"))
.andExpect(status().isBadRequest());
});
}

View File

@@ -64,17 +64,18 @@ class Ft546ShadowEndToEndTest {
private static final String FIXTURES = "/fixtures/";
@Test
void ordinaryCrossFloorAndTwoStageFixturesReachVirtualPlcEvidence()
void ordinaryPredictsWhileHandoffPhasesStayObservationOnly()
throws Exception {
ScenarioResult ordinary = run("ordinary-cycle.json");
ScenarioResult crossFloor = run("cross-floor-cycle.json");
ScenarioResult twoStage = run("two-stage-cycle.json");
assertComparable(ordinary, "4004", "4000", "4002", "4021");
assertComparable(crossFloor, "4004", "4000", "4002", "4021",
"4020", "4022");
assertComparable(twoStage, "4404", "4422", "4423", "4402",
"4400");
for (ScenarioResult special : Arrays.asList(crossFloor, twoStage)) {
assertTrue(special.cycle.getDecisions().getGranted().isEmpty());
assertTrue(special.cycle.getEncodedCommands().isEmpty());
assertEquals(1, special.persistedCycles);
}
assertEquals(3, ordinary.persistedCycles + crossFloor.persistedCycles
+ twoStage.persistedCycles);
}
@@ -187,6 +188,7 @@ class Ft546ShadowEndToEndTest {
ShadowQueueFact.Builder queue = ShadowQueueFact.builder(
"shadow_ro.v_wcs_srgv_queue", fixture.queueGuid)
.taskNo(fixture.taskNo)
.issued(0).executionStatus(1)
.taskType(fixture.taskType)
.priority(Long.valueOf(fixture.priority))
.cargoPickSite(fixture.startSite)

View File

@@ -661,6 +661,7 @@ class Ft546ShadowFaultIsolationTest {
private static ShadowQueueFact queue(String table, String guid,
Instant createdAt) {
return ShadowQueueFact.builder(table, guid)
.issued(0).executionStatus(1)
.taskNo("30001")
.taskType("2")
.priority(1L)

View File

@@ -80,6 +80,90 @@ class JdbcShadowEvidenceRepositoryTest {
objectMapper, run);
}
@Test
void newInputUsesAVersionedCompressedPayload() {
ShadowCycleEvidence evidence = matchingCycle();
repository.save(evidence);
String stored = jdbc.queryForObject("SELECT captured_json FROM shadow_input_batch", String.class);
assertNotEquals(evidence.getInputJson(), stored);
assertTrue(stored.contains("ft546-input-gzip-v1"));
assertEquals(evidence.getInputJson(), repository.loadInput(evidence.getCycleId()).get("capturedJson"));
org.springframework.mock.web.MockHttpServletRequest request = new org.springframework.mock.web.MockHttpServletRequest();
request.setRemoteAddr("127.0.0.1");
org.springframework.http.ResponseEntity<Map<String, Object>> response =
new com.ruoyi.shadow.web.ShadowInputController(repository).input(evidence.getCycleId(), request);
assertEquals(200, response.getStatusCodeValue());
assertEquals(evidence.getInputJson(), response.getBody().get("capturedJson"));
assertEquals(Boolean.TRUE, response.getBody().get("hashVerified"));
assertEquals("no-store", response.getHeaders().getFirst("Cache-Control"));
assertEquals(404, new com.ruoyi.shadow.web.ShadowInputController(repository)
.input(UUID.randomUUID(), request).getStatusCodeValue());
for (String loopback : Arrays.asList("::1", "0:0:0:0:0:0:0:1", "::ffff:127.0.0.1")) {
request.setRemoteAddr(loopback);
assertEquals(200, new com.ruoyi.shadow.web.ShadowInputController(repository)
.input(evidence.getCycleId(), request).getStatusCodeValue(), loopback);
}
}
@Test
void sameBatchSharesOneRootAndRestartExportsBothInputs() {
ShadowCycleEvidence first = observation("ONE"), second = observation("TWO");
repository.saveAll(Arrays.asList(first, second));
String ref = jdbc.queryForObject("SELECT captured_json FROM shadow_input_batch WHERE cycle_id=?",
String.class, second.getCycleId());
assertTrue(ref.contains("ft546-input-ref-v1"));
assertTrue(ref.contains(first.getCycleId().toString()));
JdbcShadowEvidenceRepository restarted = new JdbcShadowEvidenceRepository(jdbc, transaction, objectMapper, run);
assertEquals(first.getInputJson(), restarted.loadInput(first.getCycleId()).get("capturedJson"));
assertEquals(second.getInputJson(), restarted.loadInput(second.getCycleId()).get("capturedJson"));
assertEquals(second.getInputBatchHash(), restarted.loadInput(second.getCycleId()).get("contentHash"));
// Legacy rows remain readable; no migration is necessary.
jdbc.update("UPDATE shadow_input_batch SET captured_json=? WHERE cycle_id=?", first.getInputJson(), first.getCycleId());
assertEquals(second.getInputJson(), restarted.loadInput(second.getCycleId()).get("capturedJson"));
}
@Test
void missingCyclicCrossRunAndTamperedReferencesFailClosed() {
ShadowCycleEvidence first = observation("ONE"), second = observation("TWO");
repository.saveAll(Arrays.asList(first, second));
ShadowInputPayloadCodec codec = new ShadowInputPayloadCodec(objectMapper);
String hash = first.getInputBatchHash();
jdbc.update("UPDATE shadow_input_batch SET captured_json=? WHERE cycle_id=?",
codec.reference(second.getCycleId(), hash), first.getCycleId());
assertThrows(ShadowEvidenceException.class, () -> repository.loadInput(second.getCycleId()));
jdbc.update("UPDATE shadow_input_batch SET captured_json=? WHERE cycle_id=?",
codec.reference(UUID.randomUUID(), hash), second.getCycleId());
assertThrows(ShadowEvidenceException.class, () -> repository.loadInput(second.getCycleId()));
jdbc.update("UPDATE shadow_input_batch SET captured_json=? WHERE cycle_id=?", "{}", first.getCycleId());
assertThrows(ShadowEvidenceException.class, () -> repository.loadInput(first.getCycleId()));
jdbc.update("UPDATE shadow_input_batch SET captured_json=? WHERE cycle_id=?",
codec.reference(first.getCycleId(), hash), second.getCycleId());
UUID otherRun = UUID.randomUUID();
jdbc.update("INSERT INTO shadow_run SELECT ?,started_at,artifact_sha256,configuration_sha256,"
+ "source_server,source_database,source_login,status FROM shadow_run WHERE run_id=?", otherRun, run.getRunId());
jdbc.update("UPDATE shadow_cycle SET run_id=? WHERE cycle_id=?", otherRun, first.getCycleId());
assertEquals("INPUT_REFERENCE_SCOPE_MISMATCH", assertThrows(ShadowEvidenceException.class,
() -> repository.loadInput(second.getCycleId())).getReasonCode());
}
@Test
void failedChildOfSharedInputRollsBackRootAndReferenceTogether() {
jdbc.execute("ALTER TABLE shadow_decision ADD CONSTRAINT ck_second CHECK (reason_code <> 'TWO')");
assertThrows(RuntimeException.class, () -> repository.saveAll(Arrays.asList(observation("ONE"), observation("TWO"))));
assertEquals(0, count("shadow_input_batch"));
assertEquals(0, count("shadow_cycle"));
assertEquals(0, count("shadow_run"));
}
private ShadowCycleEvidence observation(String parent) {
ShadowFactBatch batch = ShadowFactBatch.create(NOW, NOW, Collections.emptyList(),
Collections.emptyList(), Collections.emptyList());
return ShadowCycleEvidence.complete(run.getRunId(), batch, ShadowLegacyQueueResult.unavailable("NO_QUEUE"),
ShadowDecision.observation(parent, "OBSERVED", 1L, null, parent), null,
Collections.singletonList(new ShadowDifference(ShadowDifferenceCategory.OBSERVATION_ONLY,
parent, null, null)), NOW, objectMapper);
}
@Test
void heartbeatPersistsEmptyRunWithoutInventingCycleEvidence() throws Exception {
createTrafficSchema();
@@ -147,8 +231,7 @@ class JdbcShadowEvidenceRepositoryTest {
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));
assertEquals(evidence.getInputJson(), repository.loadInput(evidence.getCycleId()).get("capturedJson"));
}
@Test

View File

@@ -54,6 +54,7 @@ class ShadowComparisonServiceTest {
.taskNo("30001")
.taskType("2")
.agvId("VEH-1")
.startSite("070501").endSite("070502")
.pathInfo("70501,70502")
.default02("1")
.build();

View File

@@ -0,0 +1,51 @@
package com.ruoyi.shadow.evidence;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class ShadowInputPayloadCodecTest {
private final ObjectMapper mapper = new ObjectMapper();
private final ShadowInputPayloadCodec codec = new ShadowInputPayloadCodec(mapper);
@Test
void losslessUnicodeRoundTripAndLegacyRawJsonKeepTheirOriginalHash() {
String raw = "{\"maps\":\"" + String.join("", Collections.nCopies(4000, "现场点位😀")) + "\"}";
String hash = ShadowEvidenceDigest.sha256(raw);
String stored = codec.encode(raw, hash);
assertTrue(stored.length() < raw.length() / 10);
assertEquals(raw, codec.decode(stored, hash));
assertEquals(raw, codec.decode(raw, hash));
assertThrows(ShadowEvidenceException.class, () -> codec.decode(stored, ShadowEvidenceDigest.sha256("bad")));
}
@Test
void alteredDataLengthsAndFormatsFailClosed() throws Exception {
String raw = "{\"data\":\"" + String.join("", Collections.nCopies(10000, "a")) + "\"}";
String hash = ShadowEvidenceDigest.sha256(raw);
ObjectNode stored = (ObjectNode) mapper.readTree(codec.encode(raw, hash));
stored.put("utf8Bytes", 10);
assertEquals("INPUT_INFLATION_LIMIT", assertThrows(ShadowEvidenceException.class,
() -> codec.decode(stored.toString(), hash)).getReasonCode());
stored.put("utf8Bytes", ShadowInputPayloadCodec.MAX_INPUT_BYTES + 1);
assertThrows(ShadowEvidenceException.class, () -> codec.decode(stored.toString(), hash));
stored.put("utf8Bytes", raw.length()).put("data", "garbage");
assertThrows(ShadowEvidenceException.class, () -> codec.decode(stored.toString(), hash));
stored.put("storageFormat", "ft546-input-gzip-v99");
assertThrows(ShadowEvidenceException.class, () -> codec.decode(stored.toString(), hash));
assertThrows(ShadowEvidenceException.class, () -> codec.decode(raw + " ", hash));
}
@Test
void referencesRequireRepositoryResolutionAndCannotBypassHashChecks() {
UUID id = UUID.randomUUID();
String hash = ShadowEvidenceDigest.sha256("{}");
String ref = codec.reference(id, hash);
assertEquals(id, codec.referencedCycle(ref, hash));
assertThrows(ShadowEvidenceException.class, () -> codec.decode(ref, hash));
assertThrows(ShadowEvidenceException.class, () -> codec.referencedCycle(ref, "wrong"));
}
}

View File

@@ -211,6 +211,7 @@ class ShadowFactBatchValidatorTest {
.startSite("070501").endSite("070502")
.cargoPickSite("070501").cargoPlaceSite("070502")
.createdAt(null).agvId("1")
.issued(0).executionStatus(1)
.pathInfo("70501,70502,").turnPath("")
.default02("1").build();
@@ -232,6 +233,7 @@ class ShadowFactBatchValidatorTest {
.startSite("070501").endSite("070502")
.cargoPickSite("070501").cargoPlaceSite("070502")
.createdAt(NOW.minusSeconds(2)).agvId("1")
.issued(0).executionStatus(1)
.pathInfo("70501,70502,").turnPath("")
.default02("1").default06("070503").default08("2")
.build();
@@ -278,6 +280,7 @@ class ShadowFactBatchValidatorTest {
.startSite(start).endSite(end)
.cargoPickSite(start).cargoPlaceSite(end)
.createdAt(NOW.minusSeconds(2)).agvId(agvId)
.issued(0).executionStatus(1)
.pathInfo("70501,70502,").turnPath("")
.default02("1").build();
}

View File

@@ -411,6 +411,7 @@ class SqlServerShadowFactReaderTest {
ShadowLegacyQueueResult crossFloor = projector.project(
queueBuilder("CROSS", NOW).taskNo("30002")
.endSite("080501")
.default02("8").default07("8")
.pathInfo("70501,70507,80507,80501,")
.turnPath("70507,80507").build());
@@ -421,6 +422,7 @@ class SqlServerShadowFactReaderTest {
ShadowLegacyQueueResult stageOne = projector.project(
queueBuilder("STAGE-1", NOW).taskNo("30003")
.endSite("070503")
.default02("1").default08("1")
.pathInfo("70501,70503,").turnPath("").build());
assertEquals("4404,4422,4402,4400", stageOne.getWriteAddresses());
@@ -455,7 +457,7 @@ class SqlServerShadowFactReaderTest {
}
@Test
void legacyProjectionUsesTheExecutedPathEndpointsInsteadOfQueueOrigins() {
void legacyProjectionUsesTheActualSendOriginWhenTheVehicleWasRepositioned() {
ShadowQueueFact queue = queueBuilder("REPOSITIONED", NOW)
.startSite("070501").endSite("070504")
.pathInfo("070502,070503,070504,")
@@ -464,13 +466,13 @@ class SqlServerShadowFactReaderTest {
ShadowLegacyQueueResult result =
new LegacyQueueProtocolProjector().project(queue);
assertEquals(Arrays.asList(70502, 70504, 70503, 0, 0, 0, 0, 0),
result.write("4004").getValues());
assertEquals(Arrays.asList(70502, 70504, 70503, 0, 0, 0, 0, 0), result.write("4004").getValues());
}
@Test
void legacyProjectionAcceptsPersistedZeroPaddedTurnPoints() {
ShadowQueueFact queue = queueBuilder("PADDED-TURNS", NOW)
.endSite("070504")
.pathInfo("070501,070502,070504,")
.turnPath("070503,0,0,0,0,0").build();

View File

@@ -526,6 +526,7 @@ class Ft546ShadowCycleTest {
private static ShadowQueueFact queue(String table, String guid,
Instant createdAt) {
return ShadowQueueFact.builder(table, guid)
.issued(0).executionStatus(1)
.taskNo("30001")
.taskType("2")
.priority(2L)
@@ -544,6 +545,7 @@ class Ft546ShadowCycleTest {
private static ShadowQueueFact queueWithoutLegacyAssignment(
Instant createdAt) {
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "ACTIVE")
.issued(0).executionStatus(1)
.taskNo("30001")
.taskType("2")
.priority(2L)

View File

@@ -46,6 +46,11 @@ class ShadowCapturedInputReplayTest {
String raw = row.get("captured_json").asText();
assertEquals(row.get("content_hash").asText().toLowerCase(Locale.ROOT), sha256(raw));
ShadowFactBatch batch = batch(mapper.readTree(raw));
com.ruoyi.shadow.evidence.ShadowCycleEvidence reconstructed = new com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector(
UUID.randomUUID(), 1L, new ObjectMapper().findAndRegisterModules())
.insufficient(batch, "HASH_REPLAY_ONLY", batch.getDatabaseCapturedAt()).get(0);
assertEquals(raw, reconstructed.getInputJson(), "captured input must remain byte-for-byte unchanged");
assertEquals(row.get("content_hash").asText().toLowerCase(Locale.ROOT), reconstructed.getInputBatchHash());
Instant now = batch.getDatabaseCapturedAt();
ShadowFactValidation validation = new ShadowFactBatchValidator(
Duration.ofSeconds(4), Duration.ofSeconds(30)).validate(batch, now);
@@ -60,6 +65,7 @@ class ShadowCapturedInputReplayTest {
"FT546", 1L, topology -> new ShadowTrafficState(new ResourceLedger(topology),
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(now, ZoneOffset.UTC));
DispatchSnapshot snapshot = assembler.assemble(batch);
assertTrue(snapshot.getReadySteps().isEmpty(), "old capture has no queue lifecycle");
RoutePlanner planner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator(
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
@@ -81,6 +87,8 @@ class ShadowCapturedInputReplayTest {
}
assertEquals(300, inputs);
assertEquals(600, legacyAvailable);
assertEquals(0, grants);
assertEquals(0, writes);
System.out.println("CAPTURED_REPLAY independentEmptyTrafficState inputs=" + inputs
+ " legacyAvailable=" + legacyAvailable + " grants=" + grants
+ " holds=" + holds + " encodedWrites=" + writes + " holdReasons=" + holdReasons);
@@ -95,6 +103,7 @@ class ShadowCapturedInputReplayTest {
.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"))
.issued(number(q, "issued")).executionStatus(number(q, "executionStatus"))
.pathInfo(text(q, "pathInfo")).turnPath(text(q, "turnPath"))
.default02(text(q, "default02")).default03(text(q, "default03"))
.default04(text(q, "default04")).default05(text(q, "default05"))

View File

@@ -0,0 +1,58 @@
package com.ruoyi.shadow.kernel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.shadow.evidence.ShadowCycleEvidence;
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
import com.ruoyi.shadow.evidence.ShadowInputPayloadCodec;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.nio.file.Paths;
import java.time.Clock;
import java.time.ZoneOffset;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** Private field exports stay outside source control. No network or database is contacted. */
@EnabledIfSystemProperty(named = "shadow.replay.audit-directory", matches = ".+")
class ShadowFieldEvidenceReplayTest {
@Test
void auditedInputsKeepOriginalHashesAndRoundTripWithBoundedCompressedStorage() throws Exception {
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
ShadowInputPayloadCodec codec = new ShadowInputPayloadCodec(mapper);
Set<String> hashes = new HashSet<String>();
int captures = 0;
long rawBytes = 0, storedBytes = 0;
for (String filename : new String[] {"03-samples.json", "04-detail-statistics.json"}) {
JsonNode export = mapper.readTree(Paths.get(System.getProperty("shadow.replay.audit-directory"), filename).toFile());
for (JsonNode result : export.path("resultSets")) for (JsonNode row : result.path("rows")) {
if (!row.hasNonNull("captured_json")) continue;
String raw = row.get("captured_json").asText();
ShadowFactBatch batch = ShadowCapturedInputReplayTest.batch(mapper.readTree(raw));
ShadowCycleEvidence evidence = new ShadowCycleEvidenceCollector(UUID.randomUUID(), 1L, mapper)
.insufficient(batch, "RAW_INPUT_REPLAY", batch.getDatabaseCapturedAt()).get(0);
assertEquals(raw, evidence.getInputJson());
if (row.hasNonNull("content_hash")) assertEquals(row.path("content_hash").asText(), evidence.getInputBatchHash());
ShadowDispatchSnapshotAssembler assembler = new ShadowDispatchSnapshotAssembler("FT546", 1L,
topology -> new ShadowTrafficState(new ResourceLedger(topology), new WaitForGraph(), 0L, Collections.emptyMap()),
Clock.fixed(batch.getDatabaseCapturedAt(), ZoneOffset.UTC));
assertTrue(assembler.assemble(batch).getReadySteps().isEmpty(), "Old capture lacks lifecycle; do not invent pending status");
String stored = codec.encode(raw, evidence.getInputBatchHash());
assertEquals(raw, codec.decode(stored, evidence.getInputBatchHash()));
if (hashes.add(evidence.getInputBatchHash())) { rawBytes += 2L * raw.length(); storedBytes += 2L * stored.length(); }
captures++;
}
}
assertTrue(captures >= 17, "field export is incomplete");
assertTrue(storedBytes < rawBytes / 2, "field maps should compress materially");
System.out.println("FIELD_INPUT_REPLAY captures=" + captures + " uniqueInputs=" + hashes.size()
+ " originalNvarcharBytes=" + rawBytes + " compressedNvarcharBytes=" + storedBytes
+ " storedPercent=" + (100.0 * storedBytes / rawBytes));
}
}

View File

@@ -44,6 +44,115 @@ class ShadowFieldRegressionTest {
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 missingLifecycleCannotAuthorizeANewDispatch() {
ShadowQueueFact queue = queue(ACTIVE, "UNKNOWN", "080202", "080203")
.issued(null).executionStatus(null).build();
assertTrue(assembler().assemble(batch(Collections.singletonList(queue), vehicle().build()))
.getReadySteps().isEmpty());
}
@Test
void issuedCompletedUnknownAndArrivedPhasesProduceObservationEvidenceWithoutWrites() throws Exception {
for (ShadowQueueFact queue : Arrays.asList(
queue(ACTIVE, "ISSUED", "080202", "080203").issued(1).executionStatus(2).build(),
queue(ACTIVE, "DONE", "080202", "080203").issued(1).executionStatus(3).build(),
queue(ACTIVE, "UNKNOWN", "080202", "080203").issued(null).executionStatus(null).build(),
queue(ACTIVE, "ARRIVED", "080203", "080202").build(),
queue(ACTIVE, "SINGLE", "080202", "080202").build())) {
ShadowFactBatch batch = batch(Collections.singletonList(queue), vehicle().finish(1).build());
com.ruoyi.wcs.application.dispatch.DispatchSnapshot snapshot = assembler().assemble(batch);
assertTrue(snapshot.getReadySteps().isEmpty(), queue.getGuid());
com.ruoyi.wcs.application.dispatch.DispatchCycleResult decisions = coordinator().dispatch(snapshot);
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
com.ruoyi.shadow.evidence.ShadowCycleEvidence evidence = new ShadowCycleEvidenceCollector(
java.util.UUID.randomUUID(), 1L, mapper).collect(batch, decisions, Collections.emptyList(), NOW).get(0);
assertEquals("OBSERVATION_ONLY", evidence.getDecision());
assertEquals("OBSERVATION_ONLY", evidence.getStatus());
assertTrue(evidence.getEncodedWrites().isEmpty());
assertEquals(com.ruoyi.shadow.evidence.ShadowDifferenceCategory.OBSERVATION_ONLY,
evidence.getDifferences().get(0).getCategory());
if (queue.getIssued() != null) assertEquals(queue.getIssued().intValue(),
mapper.readTree(evidence.getInputJson()).path("queues").get(0).path("issued").intValue());
}
}
@Test
void pendingBindingCannotBeReassignedAndBusyBindingStaysBlocked() {
ShadowQueueFact queue = queue(ACTIVE, "PENDING", "080202", "080203").build();
ShadowFactBatch free = batch(Collections.singletonList(queue), vehicle().build());
assertEquals("2", coordinator().dispatch(assembler().assemble(free)).getGranted().get(0).getVehicleId());
ShadowVehicleFact competitor = ShadowVehicleFact.builder("OTHER", "3").currentSite("080202")
.workMode(5).status(1).allowSend(1).battery(100).currentFloor(2).capturedAt(NOW).build();
ShadowFactBatch busy = ShadowFactBatch.create(NOW, NOW, free.getQueues(),
Arrays.asList(vehicle().allowSend(0).status(2).build(), competitor), free.getMaps());
assertTrue(coordinator().dispatch(assembler().assemble(busy)).getGranted().isEmpty());
assertEquals("NO_ELIGIBLE_VEHICLE", coordinator().dispatch(assembler().assemble(busy)).getHeld().get(0).getReasonCode());
}
@Test
void onlyConfiguredPassableSitesWithAnExitCanBeUsedForYielding() {
ShadowFactBatch batch = liftBatch(queue(ACTIVE, "LIFT", "080202", "tsj")
.issued(1).executionStatus(2).theFloor("2").build());
ShadowDispatchSnapshotAssembler configured = assembler(Collections.singleton("080203"));
assertTrue(configured.assemble(batch).getTopology().node("080203").isSafeYield());
assertFalse(configured.assemble(batch).getTopology().node("070502").isSafeYield());
assertFalse(assembler().assemble(batch).getTopology().node("080203").isSafeYield());
assertThrows(RuntimeException.class, () -> assembler(Collections.singleton("080202")).assemble(batch));
assertThrows(RuntimeException.class, () -> assembler(Collections.singleton("999999")).assemble(batch));
assertThrows(RuntimeException.class, () -> assembler(Collections.singleton("TD002")));
}
@Test
void lifecycleChangesParticipateInTheQueueAndRawInputHashes() {
ShadowQueueFact pending = queue(ACTIVE, "Q", "080202", "080203").build();
ShadowQueueFact sent = queue(ACTIVE, "Q", "080202", "080203").issued(1).build();
assertNotEquals(pending.getContentHash(), sent.getContentHash());
ShadowCycleEvidenceCollector collector = new ShadowCycleEvidenceCollector(java.util.UUID.randomUUID(),
1L, new ObjectMapper().findAndRegisterModules());
assertNotEquals(collector.insufficient(batch(Collections.singletonList(pending), vehicle().build()), "TEST", NOW).get(0).getInputBatchHash(),
collector.insufficient(batch(Collections.singletonList(sent), vehicle().build()), "TEST", NOW).get(0).getInputBatchHash());
}
@Test
void observationalRowsWithIncompletePhaseFieldsDoNotInvalidatePendingWork() {
ShadowQueueFact pending = queue(ACTIVE, "LIVE", "080202", "080203").build();
ShadowQueueFact observed = queue(ACTIVE, "OLD", "tsj", "tsj")
.taskNo("503").issued(1).executionStatus(3).createdAt(null).pathInfo(null).build();
ShadowFactBatch batch = batch(Arrays.asList(pending, observed), vehicle().build());
assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus());
assertEquals(1, assembler().assemble(batch).getReadySteps().size());
}
private static DispatchCoordinator coordinator() {
AStarRoutePlanner planner = new AStarRoutePlanner();
return new DispatchCoordinator(new ShadowVehicleAssignmentPolicy(new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE)),
new DeadlockGuard(new WaitForGraph(), planner), commit -> true, () -> java.util.UUID.randomUUID().toString());
}
private static ShadowDispatchSnapshotAssembler assembler(java.util.Set<String> yieldSites) {
return new ShadowDispatchSnapshotAssembler("FT546", 1L,
topology -> new ShadowTrafficState(new ResourceLedger(topology), new WaitForGraph(), 0L, Collections.emptyMap()),
Clock.fixed(NOW, ZoneOffset.UTC), Collections.emptySet(), yieldSites);
}
@Test
void legacyLiftEndpointFollowsTheActualPlcRewrite() {
ShadowQueueFact queue = queue(ACTIVE, "LIFT", "050101", "tsj")
.theFloor("1").default02("1").pathInfo("050101,060101,060501,070501,")
.turnPath("060101,060501,").build();
assertEquals(Arrays.asList(50101, 90501, 60101, 60501),
new LegacyQueueProtocolProjector().project(queue).getPathPoints());
}
@Test
void previousPhasePathIsNotAComparableCurrentCommand() {
ShadowQueueFact queue = queue(ACTIVE, "PHASE", "030602", "tsj")
.theFloor("2").default02("2").pathInfo("090502,030602,").build();
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
new LegacyQueueProtocolProjector().project(queue).getStatus());
}
@Test
void initializedAgvFlagDoesNotHideAnAutomaticIdleSendableVehicle() {
VehicleSnapshot vehicle = assemble(vehicle().agvStatus("0").build());
@@ -122,8 +231,10 @@ class ShadowFieldRegressionTest {
.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("8".equals(type) ? "070502" : "090502",
com.ruoyi.shadow.facts.ShadowQueueEndpoints.end(queue, batch));
if ("8".equals(type)) assertTrue(assembler().assemble(batch).getReadySteps().isEmpty());
else assertEquals("090502", assembler().assemble(batch).getReadySteps().get(0).getDestinationNodeId());
assertEquals("tsj", batch.queue("LIFT").getEndSite());
}
}
@@ -248,7 +359,7 @@ class ShadowFieldRegressionTest {
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");
.turnPath("").default02("1").issued(0).executionStatus(1);
}
private static ShadowVehicleFact.Builder vehicle() {

View File

@@ -58,7 +58,7 @@ class ShadowLivePathRegressionTest {
}
@Test
void overlappingApproachAndExecutionReserveEachResourceOnceWithoutLosingRouteOrder() {
void boundPhaseMovesFromLivePositionWithoutReturningToAnOldQueueOrigin() {
DispatchSnapshot snapshot = snapshot(fieldMap(), "060301", "070101", "060201");
AStarRoutePlanner planner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator(
@@ -68,10 +68,9 @@ class ShadowLivePathRegressionTest {
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(Arrays.asList("060301", "WY0002", "060201"), route.getNodeIds());
assertEquals(new HashSet<>(route.getResourceKeys()).size(), route.getResourceKeys().size());
assertEquals(Arrays.asList(60301, 60201, 60101, 70101, 60101),
assertEquals(Arrays.asList(60301, 60201),
new ShadowCommandIntentFactory().create(result.getGranted().get(0)).getPathPoints());
}
@@ -120,6 +119,7 @@ class ShadowLivePathRegressionTest {
private static ShadowQueueFact.Builder queue(String start, String end) {
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "FIELD")
.issued(0).executionStatus(1)
.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("");

View File

@@ -0,0 +1,31 @@
package com.ruoyi.shadow.startup;
import com.ruoyi.shadow.config.ShadowProperties;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ShadowRuntimeIdentityTest {
@Test
void settingsDigestIsIndependentOfOperatorLabelsAndSecretsButTracksActualBehaviorSettings() {
ShadowProperties p = new ShadowProperties();
MockEnvironment env = new MockEnvironment();
Map<String, Object> first = ShadowRuntimeIdentity.describe(p, env);
p.getSource().setPassword("secret-source"); p.getEvidence().setPassword("secret-evidence");
p.getEvidence().getRun().setArtifactSha256("operator-label");
Map<String, Object> second = ShadowRuntimeIdentity.describe(p, env);
assertEquals(first.get("effectiveSettingsSha256"), second.get("effectiveSettingsSha256"));
assertFalse(second.toString().contains("secret-"));
env.setProperty("shadow.poll.fixed-delay-ms", "6000");
assertNotEquals(first.get("effectiveSettingsSha256"), ShadowRuntimeIdentity.describe(p, env).get("effectiveSettingsSha256"));
env.setProperty("shadow.poll.fixed-delay-ms", "5000");
p.getTraffic().setSafeYieldSites(Collections.singleton("080203"));
assertNotEquals(first.get("effectiveSettingsSha256"), ShadowRuntimeIdentity.describe(p, env).get("effectiveSettingsSha256"));
ShadowStartupReport report = ShadowStartupReport.starting(Instant.EPOCH, Collections.emptyMap());
report.recordRuntimeIdentity(second);
assertEquals(second, report.snapshotRuntimeState(Instant.EPOCH, null, false).get("runtimeIdentity"));
}
}