618 lines
21 KiB
Rust
618 lines
21 KiB
Rust
//! SIM_SPEC §5.8 behavioural property tests for the network. Each
|
|
//! `#[test]` names the §5.8 property it covers.
|
|
//!
|
|
//! Test posture is scenario-level: build a small `Scenario`, exercise
|
|
//! the network through its public `send`/`apply_mutation` API, observe
|
|
//! `SendOutcome` and `take_pending_notifications`. We never inspect
|
|
//! private state.
|
|
|
|
use std::path::Path;
|
|
|
|
use proptest::prelude::*;
|
|
use simulation::network::{
|
|
CacheTransition, DropReason, Network, NetworkNotification, SendOutcome,
|
|
};
|
|
use simulation::scenario::{
|
|
HostKindRegistry, Mutation, MutationKind, Scenario, load_from_str,
|
|
};
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Fixtures
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
fn registry() -> HostKindRegistry {
|
|
HostKindRegistry::with_swim()
|
|
}
|
|
|
|
fn fake_path() -> &'static Path {
|
|
Path::new("test://network.toml")
|
|
}
|
|
|
|
/// Two-peer scenario, both directions declared. Returns the network
|
|
/// and a clone of the scenario for any test that needs to reference
|
|
/// the policy.
|
|
fn two_peer_scenario(seed: u64, custom: &str) -> (Network, Scenario) {
|
|
let text = format!(
|
|
r#"
|
|
name = "n"
|
|
seed = {seed}
|
|
duration_ns = 10_000_000_000
|
|
|
|
[default_tick]
|
|
period_ns = 1_000_000
|
|
|
|
[default_link]
|
|
latency_ns = 1000
|
|
jitter_stddev_ns = 0
|
|
loss_prob_ppm = 0
|
|
reorder_prob_ppm = 0
|
|
bandwidth_bps = 1_000_000_000
|
|
cold_dial_penalty_ns = 0
|
|
cache_warm_after_ns = 0
|
|
cache_invalidate_after_idle_ns = 10_000_000_000
|
|
|
|
[[peers]]
|
|
id = "a"
|
|
kind = "swim"
|
|
initial_state = "alive"
|
|
kind_config = {{ probe_interval_ns = 1, suspicion_timeout_ns = 10 }}
|
|
|
|
[[peers]]
|
|
id = "b"
|
|
kind = "swim"
|
|
initial_state = "alive"
|
|
kind_config = {{ probe_interval_ns = 1, suspicion_timeout_ns = 10 }}
|
|
|
|
[[links]]
|
|
from = "a"
|
|
to = "b"
|
|
{custom}
|
|
|
|
[[links]]
|
|
from = "b"
|
|
to = "a"
|
|
"#
|
|
);
|
|
let scenario = load_from_str(fake_path(), &text, ®istry()).expect("scenario must validate");
|
|
(Network::new(&scenario), scenario)
|
|
}
|
|
|
|
fn ok(outcome: SendOutcome) -> u64 {
|
|
match outcome {
|
|
SendOutcome::Arrive { at_ns, .. } => at_ns,
|
|
SendOutcome::Drop { reason } => panic!("expected Arrive, got Drop({reason:?})"),
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// §5.8 Properties
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn reachability_returns_arrive_iff_edge_declared_and_no_partition() {
|
|
let (mut net, _) = two_peer_scenario(1, "");
|
|
|
|
// Declared edge → Arrive.
|
|
assert!(matches!(
|
|
net.send("a", "b", 64, 1_000),
|
|
SendOutcome::Arrive { .. }
|
|
));
|
|
// Drain notifications produced by the successful warm-up so the
|
|
// no-route check below is unambiguous.
|
|
let _ = net.take_pending_notifications();
|
|
|
|
// Undeclared edge → NoRoute, no state change visible (we can't
|
|
// inspect state but we can verify no notifications fired).
|
|
let outcome = net.send("a", "ghost", 64, 2_000);
|
|
assert!(matches!(
|
|
outcome,
|
|
SendOutcome::Drop {
|
|
reason: DropReason::NoRoute
|
|
}
|
|
));
|
|
assert!(
|
|
net.take_pending_notifications().is_empty(),
|
|
"no-route send must not produce side-channel notifications"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refused_send_leaves_network_state_unchanged() {
|
|
// §5.8 "A send the network refuses leaves the network's state
|
|
// unchanged." We verify by: do one Arrive, refuse another Send
|
|
// (via partition), then send again and check the next Arrive time
|
|
// is what the bandwidth/serialization math predicts based on only
|
|
// the first Arrive.
|
|
let (mut net, _) = two_peer_scenario(1, "");
|
|
let first_at = ok(net.send("a", "b", 64, 1_000));
|
|
|
|
let mutation = Mutation {
|
|
at_ns: 1_500,
|
|
kind: MutationKind::Partition {
|
|
peers_a: vec!["a".to_string()],
|
|
peers_b: vec!["b".to_string()],
|
|
},
|
|
};
|
|
let _ = net.apply_mutation(&mutation, 1_500);
|
|
assert!(matches!(
|
|
net.send("a", "b", 64, 2_000),
|
|
SendOutcome::Drop {
|
|
reason: DropReason::Partitioned
|
|
}
|
|
));
|
|
|
|
// Heal and send again.
|
|
let _ = net.apply_mutation(
|
|
&Mutation {
|
|
at_ns: 2_500,
|
|
kind: MutationKind::Heal,
|
|
},
|
|
2_500,
|
|
);
|
|
let third_at = ok(net.send("a", "b", 64, 3_000));
|
|
|
|
// Bandwidth-serialization should pick up from the original last_arrive,
|
|
// not from the partitioned attempt. Since the partition refusal
|
|
// changed no state, third_at math depends only on (3_000, first_at).
|
|
// We check third_at >= 3_000 + base latency. The exact value is
|
|
// implementation-defined but should be 3_000 + nanos_per_64_bytes + latency.
|
|
assert!(third_at >= 3_000 + 1_000, "third arrival too early: {third_at}");
|
|
// And the partition didn't perturb deterministic ordering: must
|
|
// be after the first arrival.
|
|
assert!(third_at > first_at);
|
|
}
|
|
|
|
#[test]
|
|
fn partition_heals_to_identity_on_subsequent_sends() {
|
|
let make_net = || two_peer_scenario(42, "").0;
|
|
|
|
let mut a = make_net();
|
|
let mut b = make_net();
|
|
|
|
// a: partition + heal at times 100, 200, then send at 1_000.
|
|
let _ = a.apply_mutation(
|
|
&Mutation {
|
|
at_ns: 100,
|
|
kind: MutationKind::Partition {
|
|
peers_a: vec!["a".into()],
|
|
peers_b: vec!["b".into()],
|
|
},
|
|
},
|
|
100,
|
|
);
|
|
let _ = a.apply_mutation(
|
|
&Mutation {
|
|
at_ns: 200,
|
|
kind: MutationKind::Heal,
|
|
},
|
|
200,
|
|
);
|
|
let _ = a.take_pending_notifications();
|
|
|
|
// b: nothing.
|
|
let _ = b.take_pending_notifications();
|
|
|
|
let send_a = a.send("a", "b", 64, 1_000);
|
|
let send_b = b.send("a", "b", 64, 1_000);
|
|
assert_eq!(send_a, send_b, "partition+heal must be indistinguishable");
|
|
}
|
|
|
|
#[test]
|
|
fn loss_is_bernoulli_with_burst_overriding_for_window_only() {
|
|
// With loss_prob_ppm = 500_000 we expect roughly half of sends to
|
|
// drop. With a 100% loss burst on the link for [t, t+dur), all
|
|
// sends in that window drop; outside the window the base rate
|
|
// resumes.
|
|
let (mut net, _) = two_peer_scenario(
|
|
99,
|
|
"loss_prob_ppm = 500_000",
|
|
);
|
|
|
|
let base_drops_before = run_n_sends_count_drops(&mut net, "a", "b", 64, 1_000, 200);
|
|
|
|
// Apply a 100% LossBurst from t=300k to t=400k.
|
|
let _ = net.apply_mutation(
|
|
&Mutation {
|
|
at_ns: 300_000,
|
|
kind: MutationKind::LossBurst {
|
|
links: vec![link_ref("a", "b")],
|
|
prob_ppm: 1_000_000,
|
|
duration_ns: 100_000,
|
|
},
|
|
},
|
|
300_000,
|
|
);
|
|
// Every send inside [300_000, 400_000) drops.
|
|
for t in (300_000..400_000).step_by(1_000) {
|
|
let outcome = net.send("a", "b", 64, t);
|
|
assert!(
|
|
matches!(outcome, SendOutcome::Drop { reason: DropReason::Lossy }),
|
|
"burst window: t={t} got {outcome:?}"
|
|
);
|
|
}
|
|
// After the burst ends, base rate ~0.5 resumes.
|
|
let base_drops_after = run_n_sends_count_drops(&mut net, "a", "b", 64, 500_000, 200);
|
|
|
|
// Sanity: under base rate 500_000ppm we expect ~100/200 drops,
|
|
// tolerated within 60..140.
|
|
for label in [base_drops_before, base_drops_after] {
|
|
assert!(
|
|
(60..=140).contains(&label),
|
|
"expected ~100 drops out of 200 at base rate, saw {label}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn run_n_sends_count_drops(
|
|
net: &mut Network,
|
|
from: &str,
|
|
to: &str,
|
|
bytes: u64,
|
|
start_ns: u64,
|
|
n: u64,
|
|
) -> usize {
|
|
let mut drops = 0;
|
|
for i in 0..n {
|
|
let outcome = net.send(from, to, bytes, start_ns + i * 1_000);
|
|
if matches!(outcome, SendOutcome::Drop { reason: DropReason::Lossy }) {
|
|
drops += 1;
|
|
}
|
|
}
|
|
drops
|
|
}
|
|
|
|
fn link_ref(from: &str, to: &str) -> simulation::scenario::LinkRef {
|
|
simulation::scenario::LinkRef {
|
|
from: from.into(),
|
|
to: to.into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bandwidth_serializes_no_overlap_between_messages() {
|
|
// bandwidth_bps is bytes/sec per §5.2. With 1_000_000 bytes/sec and
|
|
// 1000-byte messages, each message occupies 1ms on the wire. Two
|
|
// back-to-back sends must arrive at least ~1ms apart.
|
|
let (mut net, _) = two_peer_scenario(
|
|
1,
|
|
"bandwidth_bps = 1_000_000\nlatency_ns = 0",
|
|
);
|
|
let a1 = ok(net.send("a", "b", 1_000, 0)); // 1 ms occupancy
|
|
let a2 = ok(net.send("a", "b", 1_000, 0));
|
|
let gap = a2 - a1;
|
|
// 1000 bytes / 1_000_000 bytes_per_sec = 1ms. ±1 ns of rounding.
|
|
assert!(
|
|
(999_999..=1_000_001).contains(&gap),
|
|
"expected ~1ms gap, got {gap}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn latency_is_additive_decomposes_per_5_4() {
|
|
// With latency_ns=L, no jitter, no spikes, no relay buffer, zero
|
|
// bandwidth-occupancy (bytes=0 makes the serialisation contribution
|
|
// zero), an Arrive at time t is at exactly t + L (plus cold-dial
|
|
// penalty on the first call). Space the second send out beyond
|
|
// the first arrival so bandwidth serialisation doesn't push it.
|
|
let (mut net, _) = two_peer_scenario(
|
|
1,
|
|
"latency_ns = 5000\nbandwidth_bps = 1_000_000_000\ncold_dial_penalty_ns = 0",
|
|
);
|
|
let a1 = ok(net.send("a", "b", 0, 100));
|
|
let a2 = ok(net.send("a", "b", 0, 20_000));
|
|
assert_eq!(a1, 100 + 5000);
|
|
assert_eq!(a2, 20_000 + 5000);
|
|
}
|
|
|
|
#[test]
|
|
fn jitter_is_symmetric_and_integer_valued() {
|
|
// §5.8 "Jitter samples come from the precomputed table of §7, are
|
|
// symmetric around zero, and are never non-integer."
|
|
// The integer property is encoded in the function signature (i64);
|
|
// we test symmetry on the table itself.
|
|
use simulation::rng::GAUSSIAN_TABLE_1024;
|
|
for i in 0..GAUSSIAN_TABLE_1024.len() {
|
|
let j = GAUSSIAN_TABLE_1024.len() - 1 - i;
|
|
assert_eq!(
|
|
GAUSSIAN_TABLE_1024[i] + GAUSSIAN_TABLE_1024[j],
|
|
0,
|
|
"table[{i}] + table[{j}] != 0",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cache_state_warm_after_traffic_and_cold_again_after_idle() {
|
|
// SIM_SPEC §5.4: DialStart + DialOutcome fire at Cold→Warming.
|
|
// The Warmed CacheStateChange fires only at Warming→Warm,
|
|
// i.e. when a send happens at or after `since + cache_warm_after_ns`.
|
|
let (mut net, _) = two_peer_scenario(
|
|
1,
|
|
"cold_dial_penalty_ns = 10_000\ncache_warm_after_ns = 1_000\ncache_invalidate_after_idle_ns = 5_000",
|
|
);
|
|
let cold_at = ok(net.send("a", "b", 64, 0));
|
|
let notes = net.take_pending_notifications();
|
|
// First send: only DialStart + DialOutcome at the cold→warming edge.
|
|
assert!(notes.iter().any(|n| matches!(n, NetworkNotification::DialStart { .. })));
|
|
assert!(notes.iter().any(|n| matches!(n, NetworkNotification::DialOutcome { .. })));
|
|
assert!(
|
|
!notes.iter().any(|n| matches!(
|
|
n,
|
|
NetworkNotification::CacheStateChange {
|
|
transition: CacheTransition::Warmed,
|
|
..
|
|
}
|
|
)),
|
|
"Cold→Warming must not emit CacheStateChange{{Warmed}} per §5.4 step 10"
|
|
);
|
|
|
|
// Second send past warm_after — should now be warm (no cold-dial
|
|
// penalty) AND emit exactly one CacheStateChange{Warmed} at the
|
|
// actual transition time (since + warm_after).
|
|
let warm_at = ok(net.send("a", "b", 64, 2_000));
|
|
let notes = net.take_pending_notifications();
|
|
let warmed_notes: Vec<&NetworkNotification> = notes
|
|
.iter()
|
|
.filter(|n| {
|
|
matches!(
|
|
n,
|
|
NetworkNotification::CacheStateChange {
|
|
transition: CacheTransition::Warmed,
|
|
..
|
|
}
|
|
)
|
|
})
|
|
.collect();
|
|
assert_eq!(warmed_notes.len(), 1, "Warming→Warm must emit exactly one Warmed");
|
|
if let NetworkNotification::CacheStateChange { at_ns, .. } = warmed_notes[0] {
|
|
// since_ns was 0; warm_after is 1_000; transition fires at 1_000.
|
|
assert_eq!(*at_ns, 1_000);
|
|
}
|
|
assert!(
|
|
warm_at < cold_at + 10_000,
|
|
"second send should not pay the 10us cold-dial penalty"
|
|
);
|
|
|
|
// Now skip ahead past the idle-invalidation horizon.
|
|
let idle_send_at = ok(net.send("a", "b", 64, 2_000 + 10_000));
|
|
let notes = net.take_pending_notifications();
|
|
assert!(notes.iter().any(|n| matches!(
|
|
n,
|
|
NetworkNotification::CacheStateChange {
|
|
transition: CacheTransition::IdleCooled,
|
|
..
|
|
}
|
|
)));
|
|
assert!(idle_send_at >= 2_000 + 10_000);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_transitions_emit_exactly_one_notification_each() {
|
|
// §5.8 "Every cold↔warm transition emits exactly one
|
|
// CacheStateChange notification at the transition's virtual time."
|
|
// We construct a Cold→Warming→Warm→IdleCooled sequence with a
|
|
// non-zero cache_warm_after_ns so that the Cold→Warming and
|
|
// Warming→Warm edges are distinguishable.
|
|
let (mut net, _) = two_peer_scenario(
|
|
1,
|
|
"cold_dial_penalty_ns = 10_000\ncache_warm_after_ns = 1_000\ncache_invalidate_after_idle_ns = 5_000",
|
|
);
|
|
// First send: Cold→Warming. No Warmed yet.
|
|
let _ = net.send("a", "b", 64, 0);
|
|
let warmed_step1: usize = net
|
|
.take_pending_notifications()
|
|
.iter()
|
|
.filter(|n| matches!(
|
|
n,
|
|
NetworkNotification::CacheStateChange {
|
|
transition: CacheTransition::Warmed,
|
|
..
|
|
}
|
|
))
|
|
.count();
|
|
assert_eq!(warmed_step1, 0, "no Warmed at Cold→Warming");
|
|
|
|
// Second send still within warm-after — still Warming, no Warmed.
|
|
let _ = net.send("a", "b", 64, 500);
|
|
let warmed_step2: usize = net
|
|
.take_pending_notifications()
|
|
.iter()
|
|
.filter(|n| matches!(
|
|
n,
|
|
NetworkNotification::CacheStateChange {
|
|
transition: CacheTransition::Warmed,
|
|
..
|
|
}
|
|
))
|
|
.count();
|
|
assert_eq!(warmed_step2, 0, "still Warming below threshold");
|
|
|
|
// Third send past the threshold: Warming→Warm. Exactly one Warmed.
|
|
let _ = net.send("a", "b", 64, 1_500);
|
|
let warmed_step3: usize = net
|
|
.take_pending_notifications()
|
|
.iter()
|
|
.filter(|n| matches!(
|
|
n,
|
|
NetworkNotification::CacheStateChange {
|
|
transition: CacheTransition::Warmed,
|
|
..
|
|
}
|
|
))
|
|
.count();
|
|
assert_eq!(warmed_step3, 1, "Warming→Warm must emit exactly one Warmed");
|
|
|
|
// Many warm sends — no new transition notifications.
|
|
for t in 1_600..2_000 {
|
|
let _ = net.send("a", "b", 64, t);
|
|
}
|
|
let none: usize = net
|
|
.take_pending_notifications()
|
|
.iter()
|
|
.filter(|n| matches!(n, NetworkNotification::CacheStateChange { .. }))
|
|
.count();
|
|
assert_eq!(none, 0, "no transition should re-emit while Warm");
|
|
|
|
// Idle cool: send after the invalidate-after-idle horizon.
|
|
let _ = net.send("a", "b", 64, 2_000 + 10_000);
|
|
let cooled: usize = net
|
|
.take_pending_notifications()
|
|
.iter()
|
|
.filter(|n| matches!(
|
|
n,
|
|
NetworkNotification::CacheStateChange {
|
|
transition: CacheTransition::IdleCooled,
|
|
..
|
|
}
|
|
))
|
|
.count();
|
|
assert_eq!(cooled, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn mutation_scoping_affects_only_named_links_and_window() {
|
|
// Two parallel links a→b and b→a. LossBurst on a→b only must not
|
|
// perturb b→a draws. Outside the window, base rate resumes on
|
|
// a→b.
|
|
let (mut net, _) = two_peer_scenario(13, "");
|
|
let _ = net.apply_mutation(
|
|
&Mutation {
|
|
at_ns: 0,
|
|
kind: MutationKind::LossBurst {
|
|
links: vec![link_ref("a", "b")],
|
|
prob_ppm: 1_000_000,
|
|
duration_ns: 1_000,
|
|
},
|
|
},
|
|
0,
|
|
);
|
|
|
|
// In-window a→b drops every send.
|
|
for t in 0..500 {
|
|
let outcome = net.send("a", "b", 64, t);
|
|
assert!(matches!(outcome, SendOutcome::Drop { reason: DropReason::Lossy }));
|
|
}
|
|
// Same window b→a is unaffected.
|
|
for t in 0..500 {
|
|
let outcome = net.send("b", "a", 64, t);
|
|
assert!(matches!(outcome, SendOutcome::Arrive { .. }), "b→a unaffected");
|
|
}
|
|
// After the window, a→b sends succeed again.
|
|
for t in 1_001..1_100 {
|
|
let outcome = net.send("a", "b", 64, t);
|
|
assert!(matches!(outcome, SendOutcome::Arrive { .. }));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mutation_invalidation_returns_only_currently_in_flight_deliveries() {
|
|
let (mut net, _) = two_peer_scenario(1, "");
|
|
let SendOutcome::Arrive { delivery_id: id1, .. } = net.send("a", "b", 64, 0) else { panic!() };
|
|
let SendOutcome::Arrive { delivery_id: id2, .. } = net.send("a", "b", 64, 100) else { panic!() };
|
|
|
|
// Partition cuts a→b. Both deliveries should be invalidated.
|
|
let inv = net.apply_mutation(
|
|
&Mutation {
|
|
at_ns: 200,
|
|
kind: MutationKind::Partition {
|
|
peers_a: vec!["a".into()],
|
|
peers_b: vec!["b".into()],
|
|
},
|
|
},
|
|
200,
|
|
);
|
|
let returned_ids: Vec<_> = inv.iter().map(|d| d.delivery_id).collect();
|
|
assert!(returned_ids.contains(&id1));
|
|
assert!(returned_ids.contains(&id2));
|
|
assert_eq!(returned_ids.len(), 2);
|
|
|
|
// A subsequent partition (already partitioned) should return
|
|
// nothing new.
|
|
let inv2 = net.apply_mutation(
|
|
&Mutation {
|
|
at_ns: 300,
|
|
kind: MutationKind::Partition {
|
|
peers_a: vec!["a".into()],
|
|
peers_b: vec!["b".into()],
|
|
},
|
|
},
|
|
300,
|
|
);
|
|
assert!(inv2.is_empty(), "no new invalidations expected");
|
|
}
|
|
|
|
#[test]
|
|
fn substream_isolation_one_link_does_not_perturb_another() {
|
|
// §5.8 "Editing one link's policy must not change any draw the
|
|
// network makes on any other link." We construct two networks,
|
|
// identical except for a→b's latency_ns, and verify that b→a's
|
|
// arrivals are identical across both.
|
|
let (mut net_a, _) = two_peer_scenario(7, "latency_ns = 1000");
|
|
let (mut net_b, _) = two_peer_scenario(
|
|
7,
|
|
"latency_ns = 999_999\njitter_stddev_ns = 0",
|
|
);
|
|
|
|
// Drive b→a deterministically with the same inputs and compare
|
|
// outcomes. (Loss rate is 0 so we expect Arrive every time.)
|
|
for t in 0..50 {
|
|
let a_out = net_a.send("b", "a", 100, t * 1_000);
|
|
let b_out = net_b.send("b", "a", 100, t * 1_000);
|
|
assert_eq!(
|
|
a_out, b_out,
|
|
"b→a outcomes diverged at t={t} despite only a→b policy changing"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dial_outcome_at_ns_equals_the_post_penalty_arrival_time() {
|
|
// §5.4 step 8: DialOutcome's `at_ns` is the arrival of the
|
|
// send that triggered the cold dial — i.e. the post-penalty
|
|
// arrival, not the pre-penalty one. Tested explicitly because
|
|
// a misplaced capture in `send` historically labelled the
|
|
// notification with the pre-penalty time, leaving bundle
|
|
// consumers a phantom gap equal to `cold_dial_penalty_ns`.
|
|
let (mut net, _) = two_peer_scenario(
|
|
1,
|
|
"latency_ns = 100_000\njitter_stddev_ns = 0\ncold_dial_penalty_ns = 50_000\nbandwidth_bps = 1_000_000_000\ncache_warm_after_ns = 0\ncache_invalidate_after_idle_ns = 1_000_000_000_000",
|
|
);
|
|
let outcome = net.send("a", "b", 64, 0);
|
|
let arrival = match outcome {
|
|
SendOutcome::Arrive { at_ns, .. } => at_ns,
|
|
_ => panic!("expected Arrive"),
|
|
};
|
|
let notes = net.take_pending_notifications();
|
|
let dial_outcome = notes
|
|
.iter()
|
|
.find_map(|n| match n {
|
|
NetworkNotification::DialOutcome { at_ns, .. } => Some(*at_ns),
|
|
_ => None,
|
|
})
|
|
.expect("DialOutcome must fire on cold dial");
|
|
assert_eq!(
|
|
dial_outcome, arrival,
|
|
"DialOutcome at_ns must equal the post-penalty delivery time"
|
|
);
|
|
}
|
|
|
|
proptest! {
|
|
#![proptest_config(ProptestConfig::with_cases(16))]
|
|
|
|
/// §5.8 "Determinism. Same topology, same seed, same query
|
|
/// sequence ⇒ identical SendOutcome sequence."
|
|
#[test]
|
|
fn determinism_same_seed_same_queries(seed in 0i64..i64::MAX, n_ops in 5usize..40usize) {
|
|
let seed_u = seed as u64;
|
|
let (mut net_a, _) = two_peer_scenario(seed_u, "loss_prob_ppm = 250_000\njitter_stddev_ns = 100");
|
|
let (mut net_b, _) = two_peer_scenario(seed_u, "loss_prob_ppm = 250_000\njitter_stddev_ns = 100");
|
|
|
|
let mut a_seq = Vec::new();
|
|
let mut b_seq = Vec::new();
|
|
for i in 0..n_ops {
|
|
a_seq.push(net_a.send("a", "b", 64, (i as u64) * 1000));
|
|
b_seq.push(net_b.send("a", "b", 64, (i as u64) * 1000));
|
|
}
|
|
prop_assert_eq!(a_seq, b_seq);
|
|
}
|
|
}
|