test: add sixty-case shadow simulation matrix
This commit is contained in:
@@ -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 业务队列。
|
||||
|
||||
|
||||
@@ -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<Arguments> simulationCases() throws Exception {
|
||||
InputStream input = ShadowSimulationDataTest.class.getResourceAsStream(
|
||||
"/shadow/scenarios/shadow-simulation-60.json");
|
||||
JsonNode root = new ObjectMapper().readTree(input);
|
||||
List<Arguments> cases = new ArrayList<Arguments>();
|
||||
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<ShadowDecision> 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<ShadowDecision> 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<ShadowDecision> 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<ShadowDecision> 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<ShadowCandidate> candidates = Arrays.asList(
|
||||
candidate(id + "-2", variant % 2 == 0 ? "C" : "B", null),
|
||||
candidate(id + "-1", "A", null));
|
||||
List<ShadowDecision> first = policy().decide(candidates, ledger(), map());
|
||||
List<ShadowDecision> second = policy().decide(candidates, ledger(), map());
|
||||
assertEquals(first, second, id + " " + expected);
|
||||
}
|
||||
|
||||
private List<ShadowDecision> 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<ShadowDecision> 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<String> nodes =
|
||||
new LinkedHashSet<String>(Arrays.asList("A", "B", "C", "D"));
|
||||
LinkedHashSet<TrafficMapCompiler.EdgeDefinition> edges =
|
||||
new LinkedHashSet<TrafficMapCompiler.EdgeDefinition>();
|
||||
edges.add(new TrafficMapCompiler.EdgeDefinition("A", "B"));
|
||||
edges.add(new TrafficMapCompiler.EdgeDefinition("B", "C"));
|
||||
edges.add(new TrafficMapCompiler.EdgeDefinition("B", "D"));
|
||||
LinkedHashMap<ResourceKey, Integer> capacities = new LinkedHashMap<ResourceKey, Integer>();
|
||||
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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
Reference in New Issue
Block a user