test: add sixty-case shadow simulation matrix

This commit is contained in:
zgw
2026-07-30 15:09:02 +08:00
parent c14b1042eb
commit 5b4b99420b
3 changed files with 300 additions and 0 deletions

View File

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

View File

@@ -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"}
]