diff --git a/javaTest/README.md b/javaTest/README.md index a9afe4d..3b00813 100644 --- a/javaTest/README.md +++ b/javaTest/README.md @@ -36,6 +36,7 @@ - SHADOW PLC、业务状态和权威预约三类禁写端口。 - JSON 黄金决策事件、SHA-256 输入哈希和离线回放。 - 固定种子 `546001` 至 `546010` 的容量、无环和确定性不变量回归。 +- `shadow-simulation-60.json` 提供 60 条独立模拟数据,覆盖 12 类资源、决策、快照和反馈场景;每条数据由参数化测试独立计数。 `com.ft546.wcs.demo` 是 LEGACY 演示;新增能力位于 `com.ft546.wcs.shadow`,交通区段状态不会写入 LEGACY 业务队列。 diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/ShadowSimulationDataTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/ShadowSimulationDataTest.java new file mode 100644 index 0000000..2d404a2 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/ShadowSimulationDataTest.java @@ -0,0 +1,237 @@ +package com.ft546.wcs.shadow; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ft546.wcs.shadow.decision.DecisionType; +import com.ft546.wcs.shadow.decision.RollingDispatchPolicy; +import com.ft546.wcs.shadow.decision.ShadowCandidate; +import com.ft546.wcs.shadow.decision.ShadowDecision; +import com.ft546.wcs.shadow.feedback.ExecutionFeedback; +import com.ft546.wcs.shadow.feedback.FeedbackReconciler; +import com.ft546.wcs.shadow.feedback.FeedbackResult; +import com.ft546.wcs.shadow.map.EffectiveTrafficMap; +import com.ft546.wcs.shadow.map.TrafficMapCompiler; +import com.ft546.wcs.shadow.resource.ResourceKey; +import com.ft546.wcs.shadow.resource.ResourceLedger; +import com.ft546.wcs.shadow.segment.SegmentEvent; +import com.ft546.wcs.shadow.segment.TrafficSegment; +import com.ft546.wcs.shadow.snapshot.SnapshotFreshnessPolicy; +import com.ft546.wcs.shadow.snapshot.TrafficVehicleSnapshot; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.InputStream; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ShadowSimulationDataTest { + + @ParameterizedTest(name = "{0} {1} expected={3}") + @MethodSource("simulationCases") + void executesSimulationRecord(String id, String type, int variant, String expected) { + switch (type) { + case "SAME_NODE_HOLD": sameNode(id, expected); break; + case "OPPOSITE_EDGE_HOLD": oppositeEdge(id, expected); break; + case "PARALLEL_GRANT": parallel(id, expected); break; + case "CAPACITY_TWO": capacityTwo(id, expected); break; + case "COMMITTED_BLOCK": committedBlocks(id, expected); break; + case "SAFE_YIELD": safeYield(id, expected); break; + case "NO_SAFE_YIELD": noSafeYield(id, expected); break; + case "WAIT_CYCLE": waitCycle(id, expected); break; + case "FEEDBACK_PROGRESS": feedbackProgress(id, expected, variant); break; + case "FEEDBACK_FAIL_CLOSED": feedbackFailClosed(id, expected, variant); break; + case "SNAPSHOT_FAIL_CLOSED": snapshotFailClosed(id, expected, variant); break; + case "DETERMINISTIC_REPLAY": deterministic(id, expected, variant); break; + default: throw new IllegalArgumentException("unknown simulation type " + type); + } + } + + static Stream simulationCases() throws Exception { + InputStream input = ShadowSimulationDataTest.class.getResourceAsStream( + "/shadow/scenarios/shadow-simulation-60.json"); + JsonNode root = new ObjectMapper().readTree(input); + List cases = new ArrayList(); + for (JsonNode node : root) { + cases.add(Arguments.of(node.path("id").asText(), node.path("type").asText(), + node.path("variant").asInt(), node.path("expected").asText())); + } + assertEquals(60, cases.size(), "simulation input count"); + return cases.stream(); + } + + private void sameNode(String id, String expected) { + ResourceLedger ledger = ledger(); + ledger.evaluate("BLOCKER-" + id, Collections.singleton(ResourceKey.node("B"))); + ShadowDecision decision = decide(ledger, candidate(id, "B", null)).get(0); + assertEquals(expected, decision.getType().name(), id); + } + + private void oppositeEdge(String id, String expected) { + ResourceLedger ledger = ledger(); + ledger.evaluate("BLOCKER-" + id, Collections.singleton(ResourceKey.edge("A", "B"))); + ShadowCandidate candidate = ShadowCandidate.waiting( + id, "V-" + id, false, 1, 1, 1, + Collections.singleton(ResourceKey.edge("B", "A")), null); + assertEquals(expected, decide(ledger, candidate).get(0).getType().name(), id); + } + + private void parallel(String id, String expected) { + List decisions = policy().decide(Arrays.asList( + candidate(id + "-A", "A", null), candidate(id + "-C", "C", null)), + ledger(), map()); + assertEquals(2, count(decisions, DecisionType.valueOf(expected)), id); + } + + private void capacityTwo(String id, String expected) { + ResourceLedger ledger = ledger(); + ledger.evaluate("V0-" + id, Collections.singleton(ResourceKey.conflict("WIDE"))); + List decisions = policy().decide(Arrays.asList( + conflictCandidate(id + "-1"), conflictCandidate(id + "-2")), ledger, map()); + assertEquals(1, count(decisions, DecisionType.GRANT), id + " " + expected); + assertEquals(1, count(decisions, DecisionType.HOLD), id + " " + expected); + } + + private void committedBlocks(String id, String expected) { + List decisions = policy().decide(Arrays.asList( + ShadowCandidate.committed(id + "-H", "VH-" + id, + Collections.singleton(ResourceKey.node("B"))), + candidate(id + "-W", "B", null)), ledger(), map()); + assertEquals(DecisionType.GRANT, decisions.get(0).getType(), id + " " + expected); + assertEquals(DecisionType.HOLD, decisions.get(1).getType(), id + " " + expected); + } + + private void safeYield(String id, String expected) { + ResourceLedger ledger = ledger(); + ledger.evaluate("BLOCKER-" + id, Collections.singleton(ResourceKey.node("B"))); + assertEquals(expected, decide(ledger, candidate(id, "B", "D")).get(0).getType().name(), id); + } + + private void noSafeYield(String id, String expected) { + ResourceLedger ledger = ledger(); + ledger.evaluate("BLOCKER-" + id, Collections.singleton(ResourceKey.node("B"))); + ShadowDecision decision = decide(ledger, candidate(id, "B", "UNPUBLISHED")).get(0); + assertEquals(expected, decision.getType().name(), id); + assertEquals("NO_SAFE_YIELD_SITE", decision.getReasonCode(), id); + } + + private void waitCycle(String id, String expected) { + ResourceLedger ledger = ledger(); + ledger.evaluate("V2-" + id, Collections.singleton(ResourceKey.node("A"))); + ledger.evaluate("V1-" + id, Collections.singleton(ResourceKey.node("B"))); + List decisions = policy().decide(Arrays.asList( + ShadowCandidate.waiting(id + "-1", "V1-" + id, false, 2, 2, 1, + Collections.singleton(ResourceKey.node("A")), null), + ShadowCandidate.waiting(id + "-2", "V2-" + id, false, 1, 1, 1, + Collections.singleton(ResourceKey.node("B")), null)), ledger, map()); + assertEquals(expected, decisions.get(1).getType().name(), id); + assertEquals("WAIT_CYCLE", decisions.get(1).getReasonCode(), id); + } + + private void feedbackProgress(String id, String expected, int variant) { + FeedbackResult result = new FeedbackReconciler().reconcile( + committedSegment(id), feedback("BOOT-1", 2L, 10L + variant, + "B", Arrays.asList("B", "C"), true, true)); + assertFalse(result.isQuarantined(), id); + assertTrue(result.getReleasedResources().contains(ResourceKey.node("A")), id + " " + expected); + } + + private void feedbackFailClosed(String id, String expected, int variant) { + ExecutionFeedback feedback; + if (variant == 1) feedback = feedback("BOOT-1", 3L, 11L, "B", Arrays.asList("B", "C"), true, true); + else if (variant == 2) feedback = feedback("BOOT-2", 2L, 12L, "B", Arrays.asList("B", "C"), true, true); + else if (variant == 3) feedback = feedback("BOOT-1", 2L, 13L, "B", Arrays.asList("B", "C"), false, true); + else if (variant == 4) feedback = feedback("BOOT-1", 2L, 14L, "B", Arrays.asList("B", "C"), true, false); + else feedback = feedback("BOOT-1", 2L, 15L, "C", Arrays.asList("C", "B"), true, true); + FeedbackResult result = new FeedbackReconciler().reconcile(committedSegment(id), feedback); + assertTrue(result.isQuarantined(), id + " " + expected); + assertTrue(result.getReleasedResources().isEmpty(), id); + } + + private void snapshotFailClosed(String id, String expected, int variant) { + Instant now = Instant.parse("2026-07-30T06:00:00Z"); + TrafficVehicleSnapshot previous = snapshot("BOOT-1", 10L, now.minusSeconds(1), true, false, false); + TrafficVehicleSnapshot current; + if (variant == 1) current = snapshot("BOOT-1", 11L, now.minusSeconds(4), true, false, false); + else if (variant == 2) current = snapshot("BOOT-2", 11L, now, true, false, false); + else if (variant == 3) current = snapshot("BOOT-1", 11L, now, false, false, false); + else if (variant == 4) current = snapshot("BOOT-1", 11L, now, true, true, false); + else current = snapshot("BOOT-1", 11L, now, true, false, true); + assertFalse(new SnapshotFreshnessPolicy(Duration.ofSeconds(2)) + .validate(previous, current, now).isHealthy(), id + " " + expected); + } + + private void deterministic(String id, String expected, int variant) { + List candidates = Arrays.asList( + candidate(id + "-2", variant % 2 == 0 ? "C" : "B", null), + candidate(id + "-1", "A", null)); + List first = policy().decide(candidates, ledger(), map()); + List second = policy().decide(candidates, ledger(), map()); + assertEquals(first, second, id + " " + expected); + } + + private List decide(ResourceLedger ledger, ShadowCandidate candidate) { + return policy().decide(Collections.singletonList(candidate), ledger, map()); + } + + private ShadowCandidate candidate(String id, String site, String yieldSite) { + return ShadowCandidate.waiting(id, "V-" + id, false, 1, 1, 1, + Collections.singleton(ResourceKey.node(site)), yieldSite); + } + + private ShadowCandidate conflictCandidate(String id) { + return ShadowCandidate.waiting(id, "V-" + id, false, 1, 1, 1, + Collections.singleton(ResourceKey.conflict("WIDE")), null); + } + + private long count(List decisions, DecisionType type) { + return decisions.stream().filter(decision -> decision.getType() == type).count(); + } + + private RollingDispatchPolicy policy() { return new RollingDispatchPolicy(); } + private ResourceLedger ledger() { return new ResourceLedger(map().getCapacities()); } + + private EffectiveTrafficMap map() { + LinkedHashSet nodes = + new LinkedHashSet(Arrays.asList("A", "B", "C", "D")); + LinkedHashSet edges = + new LinkedHashSet(); + edges.add(new TrafficMapCompiler.EdgeDefinition("A", "B")); + edges.add(new TrafficMapCompiler.EdgeDefinition("B", "C")); + edges.add(new TrafficMapCompiler.EdgeDefinition("B", "D")); + LinkedHashMap capacities = new LinkedHashMap(); + capacities.put(ResourceKey.conflict("WIDE"), 2); + return new TrafficMapCompiler().compile(new TrafficMapCompiler.MapDefinition( + "FT546", 2L, "ZONE-SIM", true, "sha-sim", "sha-sim", nodes, edges, + capacities, Collections.singleton("B"), Collections.singleton("D"))); + } + + private TrafficSegment committedSegment(String id) { + return TrafficSegment.planned("SEG-" + id, "V-" + id, "BOOT-1", 7L, 2L, 1L, + Arrays.asList("A", "B", "C")) + .transition(SegmentEvent.PREPARE).transition(SegmentEvent.PREPARED) + .transition(SegmentEvent.BEGIN_COMMIT).transition(SegmentEvent.COMMITTED); + } + + private ExecutionFeedback feedback(String boot, long version, long seq, String current, + List remaining, boolean connected, boolean ack) { + return new ExecutionFeedback(boot, 7L, version, 1L, seq, current, remaining, connected, ack); + } + + private TrafficVehicleSnapshot snapshot(String boot, long seq, Instant at, + boolean connected, boolean faulted, boolean manual) { + return new TrafficVehicleSnapshot("V1", boot, seq, seq, at, "A", "B", + Arrays.asList("A", "B"), connected, faulted, manual); + } +} diff --git a/javaTest/src/test/resources/shadow/scenarios/shadow-simulation-60.json b/javaTest/src/test/resources/shadow/scenarios/shadow-simulation-60.json new file mode 100644 index 0000000..474d1b5 --- /dev/null +++ b/javaTest/src/test/resources/shadow/scenarios/shadow-simulation-60.json @@ -0,0 +1,62 @@ +[ + {"id":"SN-01","type":"SAME_NODE_HOLD","variant":1,"expected":"HOLD"}, + {"id":"SN-02","type":"SAME_NODE_HOLD","variant":2,"expected":"HOLD"}, + {"id":"SN-03","type":"SAME_NODE_HOLD","variant":3,"expected":"HOLD"}, + {"id":"SN-04","type":"SAME_NODE_HOLD","variant":4,"expected":"HOLD"}, + {"id":"SN-05","type":"SAME_NODE_HOLD","variant":5,"expected":"HOLD"}, + {"id":"OE-01","type":"OPPOSITE_EDGE_HOLD","variant":1,"expected":"HOLD"}, + {"id":"OE-02","type":"OPPOSITE_EDGE_HOLD","variant":2,"expected":"HOLD"}, + {"id":"OE-03","type":"OPPOSITE_EDGE_HOLD","variant":3,"expected":"HOLD"}, + {"id":"OE-04","type":"OPPOSITE_EDGE_HOLD","variant":4,"expected":"HOLD"}, + {"id":"OE-05","type":"OPPOSITE_EDGE_HOLD","variant":5,"expected":"HOLD"}, + {"id":"PG-01","type":"PARALLEL_GRANT","variant":1,"expected":"GRANT"}, + {"id":"PG-02","type":"PARALLEL_GRANT","variant":2,"expected":"GRANT"}, + {"id":"PG-03","type":"PARALLEL_GRANT","variant":3,"expected":"GRANT"}, + {"id":"PG-04","type":"PARALLEL_GRANT","variant":4,"expected":"GRANT"}, + {"id":"PG-05","type":"PARALLEL_GRANT","variant":5,"expected":"GRANT"}, + {"id":"CP-01","type":"CAPACITY_TWO","variant":1,"expected":"GRANT_AND_HOLD"}, + {"id":"CP-02","type":"CAPACITY_TWO","variant":2,"expected":"GRANT_AND_HOLD"}, + {"id":"CP-03","type":"CAPACITY_TWO","variant":3,"expected":"GRANT_AND_HOLD"}, + {"id":"CP-04","type":"CAPACITY_TWO","variant":4,"expected":"GRANT_AND_HOLD"}, + {"id":"CP-05","type":"CAPACITY_TWO","variant":5,"expected":"GRANT_AND_HOLD"}, + {"id":"HC-01","type":"COMMITTED_BLOCK","variant":1,"expected":"GRANT_AND_HOLD"}, + {"id":"HC-02","type":"COMMITTED_BLOCK","variant":2,"expected":"GRANT_AND_HOLD"}, + {"id":"HC-03","type":"COMMITTED_BLOCK","variant":3,"expected":"GRANT_AND_HOLD"}, + {"id":"HC-04","type":"COMMITTED_BLOCK","variant":4,"expected":"GRANT_AND_HOLD"}, + {"id":"HC-05","type":"COMMITTED_BLOCK","variant":5,"expected":"GRANT_AND_HOLD"}, + {"id":"SY-01","type":"SAFE_YIELD","variant":1,"expected":"YIELD"}, + {"id":"SY-02","type":"SAFE_YIELD","variant":2,"expected":"YIELD"}, + {"id":"SY-03","type":"SAFE_YIELD","variant":3,"expected":"YIELD"}, + {"id":"SY-04","type":"SAFE_YIELD","variant":4,"expected":"YIELD"}, + {"id":"SY-05","type":"SAFE_YIELD","variant":5,"expected":"YIELD"}, + {"id":"NY-01","type":"NO_SAFE_YIELD","variant":1,"expected":"HOLD"}, + {"id":"NY-02","type":"NO_SAFE_YIELD","variant":2,"expected":"HOLD"}, + {"id":"NY-03","type":"NO_SAFE_YIELD","variant":3,"expected":"HOLD"}, + {"id":"NY-04","type":"NO_SAFE_YIELD","variant":4,"expected":"HOLD"}, + {"id":"NY-05","type":"NO_SAFE_YIELD","variant":5,"expected":"HOLD"}, + {"id":"WG-01","type":"WAIT_CYCLE","variant":1,"expected":"HOLD"}, + {"id":"WG-02","type":"WAIT_CYCLE","variant":2,"expected":"HOLD"}, + {"id":"WG-03","type":"WAIT_CYCLE","variant":3,"expected":"HOLD"}, + {"id":"WG-04","type":"WAIT_CYCLE","variant":4,"expected":"HOLD"}, + {"id":"WG-05","type":"WAIT_CYCLE","variant":5,"expected":"HOLD"}, + {"id":"FP-01","type":"FEEDBACK_PROGRESS","variant":1,"expected":"RELEASE"}, + {"id":"FP-02","type":"FEEDBACK_PROGRESS","variant":2,"expected":"RELEASE"}, + {"id":"FP-03","type":"FEEDBACK_PROGRESS","variant":3,"expected":"RELEASE"}, + {"id":"FP-04","type":"FEEDBACK_PROGRESS","variant":4,"expected":"RELEASE"}, + {"id":"FP-05","type":"FEEDBACK_PROGRESS","variant":5,"expected":"RELEASE"}, + {"id":"FF-01","type":"FEEDBACK_FAIL_CLOSED","variant":1,"expected":"QUARANTINE"}, + {"id":"FF-02","type":"FEEDBACK_FAIL_CLOSED","variant":2,"expected":"QUARANTINE"}, + {"id":"FF-03","type":"FEEDBACK_FAIL_CLOSED","variant":3,"expected":"QUARANTINE"}, + {"id":"FF-04","type":"FEEDBACK_FAIL_CLOSED","variant":4,"expected":"QUARANTINE"}, + {"id":"FF-05","type":"FEEDBACK_FAIL_CLOSED","variant":5,"expected":"QUARANTINE"}, + {"id":"SF-01","type":"SNAPSHOT_FAIL_CLOSED","variant":1,"expected":"UNHEALTHY"}, + {"id":"SF-02","type":"SNAPSHOT_FAIL_CLOSED","variant":2,"expected":"UNHEALTHY"}, + {"id":"SF-03","type":"SNAPSHOT_FAIL_CLOSED","variant":3,"expected":"UNHEALTHY"}, + {"id":"SF-04","type":"SNAPSHOT_FAIL_CLOSED","variant":4,"expected":"UNHEALTHY"}, + {"id":"SF-05","type":"SNAPSHOT_FAIL_CLOSED","variant":5,"expected":"UNHEALTHY"}, + {"id":"DR-01","type":"DETERMINISTIC_REPLAY","variant":1,"expected":"STABLE"}, + {"id":"DR-02","type":"DETERMINISTIC_REPLAY","variant":2,"expected":"STABLE"}, + {"id":"DR-03","type":"DETERMINISTIC_REPLAY","variant":3,"expected":"STABLE"}, + {"id":"DR-04","type":"DETERMINISTIC_REPLAY","variant":4,"expected":"STABLE"}, + {"id":"DR-05","type":"DETERMINISTIC_REPLAY","variant":5,"expected":"STABLE"} +]