use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::ant_protocol::XorName;
use saorsa_core::identity::PeerId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerificationState {
OfferReceived,
PendingVerify,
QuorumVerified,
PaidListVerified,
QueuedForFetch,
Fetching,
Stored,
FetchRetryable,
FetchAbandoned,
QuorumFailed,
QuorumInconclusive,
QuorumAbandoned,
Idle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HintPipeline {
Replica,
PaidOnly,
}
#[derive(Debug, Clone)]
pub struct VerificationEntry {
pub state: VerificationState,
pub pipeline: HintPipeline,
pub verified_sources: Vec<PeerId>,
pub tried_sources: HashSet<PeerId>,
pub created_at: Instant,
pub hint_sender: PeerId,
}
#[derive(Debug, Clone)]
pub struct FetchCandidate {
pub key: XorName,
pub distance: XorName,
pub sources: Vec<PeerId>,
}
impl Eq for FetchCandidate {}
impl PartialEq for FetchCandidate {
fn eq(&self, other: &Self) -> bool {
self.distance == other.distance && self.key == other.key
}
}
impl Ord for FetchCandidate {
fn cmp(&self, other: &Self) -> Ordering {
other
.distance
.cmp(&self.distance)
.then_with(|| self.key.cmp(&other.key))
}
}
impl PartialOrd for FetchCandidate {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PresenceEvidence {
Present,
Absent,
Unresolved,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PaidListEvidence {
Confirmed,
NotFound,
Unresolved,
}
#[derive(Debug, Clone)]
pub struct KeyVerificationEvidence {
pub presence: HashMap<PeerId, PresenceEvidence>,
pub paid_list: HashMap<PeerId, PaidListEvidence>,
}
#[derive(Debug, Clone)]
pub enum FailureEvidence {
ReplicationFailure {
peer: PeerId,
key: XorName,
},
AuditFailure {
challenge_id: u64,
challenged_peer: PeerId,
confirmed_failed_keys: Vec<XorName>,
reason: AuditFailureReason,
},
BootstrapClaimAbuse {
peer: PeerId,
first_seen: Instant,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuditFailureReason {
Timeout,
MalformedResponse,
DigestMismatch,
KeyAbsent,
Rejected,
}
#[derive(Debug, Clone)]
pub struct PeerSyncRecord {
pub last_sync: Option<Instant>,
pub cycles_since_sync: u32,
}
impl PeerSyncRecord {
#[must_use]
pub fn has_repair_opportunity(&self) -> bool {
self.last_sync.is_some() && self.cycles_since_sync >= 1
}
}
#[derive(Debug, Clone)]
struct RepairProof {
hinted_at_epoch: u64,
}
#[derive(Debug, Clone)]
struct RepairProofEntry {
close_peers: HashSet<PeerId>,
peer_proofs: HashMap<PeerId, RepairProof>,
}
impl RepairProofEntry {
fn new(close_peers: HashSet<PeerId>) -> Self {
Self {
close_peers,
peer_proofs: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RepairProofs {
proofs_by_key: HashMap<XorName, RepairProofEntry>,
}
impl RepairProofs {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn record_replica_hint_sent(
&mut self,
peer: PeerId,
key: XorName,
current_close_peers: &HashSet<PeerId>,
hinted_at_epoch: u64,
) -> bool {
self.reconcile_key_close_group(&key, current_close_peers);
if !current_close_peers.contains(&peer) {
return false;
}
let entry = self
.proofs_by_key
.entry(key)
.or_insert_with(|| RepairProofEntry::new(current_close_peers.clone()));
if entry.peer_proofs.contains_key(&peer) {
return false;
}
entry
.peer_proofs
.insert(peer, RepairProof { hinted_at_epoch });
true
}
pub fn has_mature_replica_hint(
&mut self,
peer: &PeerId,
key: &XorName,
current_close_peers: &HashSet<PeerId>,
current_epoch: u64,
) -> bool {
self.reconcile_key_close_group(key, current_close_peers);
self.proofs_by_key
.get(key)
.and_then(|entry| entry.peer_proofs.get(peer))
.is_some_and(|proof| proof.hinted_at_epoch < current_epoch)
}
pub fn remove_key(&mut self, key: &XorName) {
self.proofs_by_key.remove(key);
}
pub fn remove_peer(&mut self, peer: &PeerId) {
self.proofs_by_key.retain(|_, entry| {
entry.peer_proofs.remove(peer);
!entry.peer_proofs.is_empty()
});
}
fn reconcile_key_close_group(&mut self, key: &XorName, current_close_peers: &HashSet<PeerId>) {
let should_remove = if let Some(entry) = self.proofs_by_key.get_mut(key) {
if entry.close_peers == *current_close_peers {
return;
}
entry.close_peers.clone_from(current_close_peers);
entry
.peer_proofs
.retain(|peer, _| current_close_peers.contains(peer));
entry.peer_proofs.is_empty()
} else {
false
};
if should_remove {
self.proofs_by_key.remove(key);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootstrapClaimObservation {
WithinGrace {
first_seen: Instant,
},
PastGrace {
first_seen: Instant,
},
Repeated {
first_seen: Instant,
},
}
#[derive(Debug)]
pub struct NeighborSyncState {
pub order: Vec<PeerId>,
pub cursor: usize,
pub last_sync_times: HashMap<PeerId, Instant>,
pub bootstrap_claims: HashMap<PeerId, Instant>,
pub bootstrap_claim_history: HashMap<PeerId, Instant>,
pub prune_cursor: usize,
}
impl NeighborSyncState {
#[must_use]
pub fn new_cycle(close_neighbors: Vec<PeerId>) -> Self {
Self {
order: close_neighbors,
cursor: 0,
last_sync_times: HashMap::new(),
bootstrap_claims: HashMap::new(),
bootstrap_claim_history: HashMap::new(),
prune_cursor: 0,
}
}
#[must_use]
pub fn observe_bootstrap_claim(
&mut self,
peer: PeerId,
now: Instant,
grace_period: Duration,
) -> BootstrapClaimObservation {
if let Some(first_seen) = self.bootstrap_claims.get(&peer).copied() {
if now.duration_since(first_seen) > grace_period {
BootstrapClaimObservation::PastGrace { first_seen }
} else {
BootstrapClaimObservation::WithinGrace { first_seen }
}
} else if let Some(first_seen) = self.bootstrap_claim_history.get(&peer).copied() {
BootstrapClaimObservation::Repeated { first_seen }
} else {
self.bootstrap_claims.insert(peer, now);
self.bootstrap_claim_history.insert(peer, now);
BootstrapClaimObservation::WithinGrace { first_seen: now }
}
}
pub fn clear_active_bootstrap_claim(&mut self, peer: &PeerId) -> bool {
self.bootstrap_claims.remove(peer).is_some()
}
#[must_use]
pub fn is_cycle_complete(&self) -> bool {
self.cursor >= self.order.len()
}
}
#[derive(Debug)]
pub struct BootstrapState {
pub drained: bool,
pub pending_peer_requests: usize,
pub pending_keys: HashSet<XorName>,
pub capacity_rejected_sources: HashSet<PeerId>,
}
impl BootstrapState {
#[must_use]
pub fn new() -> Self {
Self {
drained: false,
pending_peer_requests: 0,
pending_keys: HashSet::new(),
capacity_rejected_sources: HashSet::new(),
}
}
#[must_use]
pub fn is_drained(&self) -> bool {
self.drained
}
pub fn remove_key(&mut self, key: &XorName) {
self.pending_keys.remove(key);
}
}
impl Default for BootstrapState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use std::collections::BinaryHeap;
use super::*;
fn peer_id_from_byte(b: u8) -> PeerId {
let mut bytes = [0u8; 32];
bytes[0] = b;
PeerId::from_bytes(bytes)
}
#[test]
fn fetch_candidate_nearest_key_has_highest_priority() {
let near = FetchCandidate {
key: [1u8; 32],
distance: [
0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0,
],
sources: vec![peer_id_from_byte(1)],
};
let far = FetchCandidate {
key: [2u8; 32],
distance: [
0xFF, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
],
sources: vec![peer_id_from_byte(2)],
};
assert!(near > far, "nearer candidate should compare greater");
let mut heap = BinaryHeap::new();
heap.push(far.clone());
heap.push(near.clone());
assert_eq!(heap.len(), 2, "heap should contain both candidates");
let first = heap.pop();
assert!(first.is_some(), "first pop should succeed");
assert_eq!(
first.map(|c| c.key),
Some(near.key),
"nearest key should pop first"
);
let second = heap.pop();
assert!(second.is_some(), "second pop should succeed");
assert_eq!(
second.map(|c| c.key),
Some(far.key),
"farthest key should pop second"
);
}
#[test]
fn fetch_candidate_same_distance_and_key_is_equal() {
let a = FetchCandidate {
key: [1u8; 32],
distance: [5u8; 32],
sources: vec![],
};
let b = FetchCandidate {
key: [1u8; 32],
distance: [5u8; 32],
sources: vec![],
};
assert_eq!(
a.cmp(&b),
Ordering::Equal,
"same distance + same key should yield Equal"
);
assert_eq!(a, b, "PartialEq must agree with Ord");
}
#[test]
fn fetch_candidate_same_distance_different_key_is_deterministic() {
let a = FetchCandidate {
key: [1u8; 32],
distance: [5u8; 32],
sources: vec![],
};
let b = FetchCandidate {
key: [2u8; 32],
distance: [5u8; 32],
sources: vec![],
};
assert_ne!(
a.cmp(&b),
Ordering::Equal,
"same distance + different key must not be Equal"
);
assert_ne!(a, b, "PartialEq must agree with Ord");
}
#[test]
fn peer_sync_record_no_sync_yet() {
let record = PeerSyncRecord {
last_sync: None,
cycles_since_sync: 0,
};
assert!(
!record.has_repair_opportunity(),
"never-synced peer has no repair opportunity"
);
}
#[test]
fn peer_sync_record_synced_but_no_cycle() {
let record = PeerSyncRecord {
last_sync: Some(Instant::now()),
cycles_since_sync: 0,
};
assert!(
!record.has_repair_opportunity(),
"synced peer with zero subsequent cycles has no repair opportunity"
);
}
#[test]
fn peer_sync_record_synced_with_cycle() {
let record = PeerSyncRecord {
last_sync: Some(Instant::now()),
cycles_since_sync: 1,
};
assert!(
record.has_repair_opportunity(),
"synced peer with >= 1 cycle should have repair opportunity"
);
}
#[test]
fn peer_sync_record_no_sync_many_cycles() {
let record = PeerSyncRecord {
last_sync: None,
cycles_since_sync: 10,
};
assert!(
!record.has_repair_opportunity(),
"never-synced peer has no repair opportunity regardless of cycle count"
);
}
#[test]
fn repair_proofs_record_sent_hint_for_close_peer() {
const HINT_EPOCH: u64 = 7;
const CURRENT_EPOCH: u64 = HINT_EPOCH + 1;
let key = [0xA1; 32];
let peer = peer_id_from_byte(1);
let close_peers = HashSet::from([peer, peer_id_from_byte(2), peer_id_from_byte(3)]);
let mut proofs = RepairProofs::new();
assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, HINT_EPOCH));
assert!(
proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH),
"sent hint should make key auditable for that peer"
);
}
#[test]
fn repair_proofs_reject_peer_outside_current_close_group() {
const HINT_EPOCH: u64 = 7;
const CURRENT_EPOCH: u64 = HINT_EPOCH + 1;
let key = [0xA2; 32];
let peer = peer_id_from_byte(1);
let close_peers = HashSet::from([peer_id_from_byte(2), peer_id_from_byte(3)]);
let mut proofs = RepairProofs::new();
assert!(!proofs.record_replica_hint_sent(peer, key, &close_peers, HINT_EPOCH));
assert!(
!proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH),
"peers outside current close group must not get repair proof"
);
}
#[test]
fn repair_proofs_require_later_epoch() {
const HINT_EPOCH: u64 = 7;
const CURRENT_EPOCH: u64 = HINT_EPOCH + 1;
let key = [0xA3; 32];
let peer = peer_id_from_byte(1);
let close_peers = HashSet::from([peer, peer_id_from_byte(2), peer_id_from_byte(3)]);
let mut proofs = RepairProofs::new();
assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, HINT_EPOCH));
assert!(
!proofs.has_mature_replica_hint(&peer, &key, &close_peers, HINT_EPOCH),
"same-cycle proof should not be audit-eligible"
);
assert!(
proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH),
"proof should mature after a later local sync-cycle epoch"
);
}
#[test]
fn repair_proofs_repeated_hint_does_not_reset_maturity() {
const HINT_EPOCH: u64 = 7;
const REPEATED_HINT_EPOCH: u64 = HINT_EPOCH + 1;
let key = [0xA5; 32];
let peer = peer_id_from_byte(1);
let close_peers = HashSet::from([peer, peer_id_from_byte(2), peer_id_from_byte(3)]);
let mut proofs = RepairProofs::new();
assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, HINT_EPOCH));
assert!(
!proofs.record_replica_hint_sent(peer, key, &close_peers, REPEATED_HINT_EPOCH),
"duplicate hint in the same close group should keep existing proof"
);
assert!(
proofs.has_mature_replica_hint(&peer, &key, &close_peers, REPEATED_HINT_EPOCH),
"duplicate hint must not reset an already mature proof"
);
}
#[test]
fn repair_proofs_retain_stable_peers_on_close_group_change() {
const HINT_EPOCH: u64 = 7;
const CURRENT_EPOCH: u64 = HINT_EPOCH + 1;
let key = [0xA7; 32];
let stable_peer = peer_id_from_byte(1);
let departing_peer = peer_id_from_byte(2);
let retained_peer = peer_id_from_byte(3);
let new_peer = peer_id_from_byte(4);
let old_group = HashSet::from([stable_peer, departing_peer, retained_peer]);
let changed_group = HashSet::from([stable_peer, retained_peer, new_peer]);
let mut proofs = RepairProofs::new();
assert!(proofs.record_replica_hint_sent(stable_peer, key, &old_group, HINT_EPOCH));
assert!(proofs.record_replica_hint_sent(departing_peer, key, &old_group, HINT_EPOCH));
assert!(
proofs.has_mature_replica_hint(&stable_peer, &key, &changed_group, CURRENT_EPOCH),
"stable peers should keep mature repair proofs across unrelated close-group churn"
);
assert!(
!proofs.has_mature_replica_hint(&departing_peer, &key, &changed_group, CURRENT_EPOCH),
"peers that left the close group should lose repair proofs"
);
assert!(
!proofs.has_mature_replica_hint(&new_peer, &key, &changed_group, CURRENT_EPOCH),
"new close-group peers need their own repair hint before auditing"
);
}
#[test]
fn repair_proofs_evicted_peer_reentry_requires_fresh_hint() {
const FIRST_HINT_EPOCH: u64 = 7;
const SECOND_HINT_EPOCH: u64 = FIRST_HINT_EPOCH + 1;
const CURRENT_EPOCH: u64 = SECOND_HINT_EPOCH + 1;
let key = [0xA3; 32];
let returning_peer = peer_id_from_byte(1);
let new_peer = peer_id_from_byte(4);
let old_group = HashSet::from([returning_peer, peer_id_from_byte(2), peer_id_from_byte(3)]);
let changed_group = HashSet::from([new_peer, peer_id_from_byte(2), peer_id_from_byte(3)]);
let mut proofs = RepairProofs::new();
assert!(proofs.record_replica_hint_sent(returning_peer, key, &old_group, FIRST_HINT_EPOCH,));
assert!(
!proofs.has_mature_replica_hint(&new_peer, &key, &changed_group, SECOND_HINT_EPOCH),
"new close-group peer should not inherit another peer's repair proof"
);
assert!(
!proofs.has_mature_replica_hint(&returning_peer, &key, &old_group, CURRENT_EPOCH),
"a peer that re-enters must receive a fresh repair hint"
);
assert!(proofs.record_replica_hint_sent(
returning_peer,
key,
&old_group,
SECOND_HINT_EPOCH,
));
assert!(
proofs.has_mature_replica_hint(&returning_peer, &key, &old_group, CURRENT_EPOCH),
"fresh repair hint after re-entry should be eligible once mature"
);
}
#[test]
fn repair_proofs_remove_peer_requires_fresh_hint_after_reentry() {
const FIRST_HINT_EPOCH: u64 = 7;
const SECOND_HINT_EPOCH: u64 = FIRST_HINT_EPOCH + 1;
const CURRENT_EPOCH: u64 = SECOND_HINT_EPOCH + 1;
let key = [0xA6; 32];
let peer = peer_id_from_byte(1);
let close_peers = HashSet::from([peer, peer_id_from_byte(2), peer_id_from_byte(3)]);
let mut proofs = RepairProofs::new();
assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, FIRST_HINT_EPOCH));
proofs.remove_peer(&peer);
assert!(
!proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH),
"routing-table removal should clear proof even if peer re-enters same close group"
);
assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, SECOND_HINT_EPOCH));
assert!(
proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH),
"fresh hint after re-entry should become eligible after a later epoch"
);
}
#[test]
fn repair_proofs_remove_key_clears_all_peer_entries() {
const HINT_EPOCH: u64 = 7;
const CURRENT_EPOCH: u64 = HINT_EPOCH + 1;
let key = [0xA4; 32];
let peer = peer_id_from_byte(1);
let close_peers = HashSet::from([peer]);
let mut proofs = RepairProofs::new();
assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, HINT_EPOCH));
proofs.remove_key(&key);
assert!(
!proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH),
"deleted local key should not retain repair proof entries"
);
}
#[test]
fn neighbor_sync_empty_cycle_is_immediately_complete() {
let state = NeighborSyncState::new_cycle(vec![]);
assert!(
state.is_cycle_complete(),
"empty neighbor list means cycle is complete"
);
}
#[test]
fn neighbor_sync_new_cycle_not_complete() {
let peers = vec![peer_id_from_byte(1), peer_id_from_byte(2)];
let state = NeighborSyncState::new_cycle(peers);
assert!(
!state.is_cycle_complete(),
"fresh cycle with peers should not be complete"
);
}
#[test]
fn neighbor_sync_cycle_completes_when_cursor_reaches_end() {
let peers = vec![
peer_id_from_byte(1),
peer_id_from_byte(2),
peer_id_from_byte(3),
];
let mut state = NeighborSyncState::new_cycle(peers);
state.cursor = 2;
assert!(
!state.is_cycle_complete(),
"cursor at len-1 should not be complete"
);
state.cursor = 3;
assert!(
state.is_cycle_complete(),
"cursor at len should be complete"
);
}
#[test]
fn neighbor_sync_cursor_past_end_is_still_complete() {
let peers = vec![peer_id_from_byte(1)];
let mut state = NeighborSyncState::new_cycle(peers);
state.cursor = 5;
assert!(
state.is_cycle_complete(),
"cursor past end should still report complete"
);
}
#[test]
fn bootstrap_claim_history_prevents_second_grace_window() {
let peer = peer_id_from_byte(9);
let mut state = NeighborSyncState::new_cycle(vec![peer]);
let first_seen = Instant::now();
let grace = Duration::from_secs(60);
assert_eq!(
state.observe_bootstrap_claim(peer, first_seen, grace),
BootstrapClaimObservation::WithinGrace { first_seen }
);
assert!(state.clear_active_bootstrap_claim(&peer));
assert!(!state.bootstrap_claims.contains_key(&peer));
assert!(state.bootstrap_claim_history.contains_key(&peer));
assert_eq!(
state.observe_bootstrap_claim(peer, first_seen + Duration::from_secs(1), grace),
BootstrapClaimObservation::Repeated { first_seen }
);
assert!(
!state.bootstrap_claims.contains_key(&peer),
"repeated claims must not recreate an active grace window"
);
assert_eq!(
state.observe_bootstrap_claim(peer, first_seen + Duration::from_secs(2), grace),
BootstrapClaimObservation::Repeated { first_seen }
);
}
#[test]
fn bootstrap_claim_active_window_reports_past_grace() {
let peer = peer_id_from_byte(10);
let mut state = NeighborSyncState::new_cycle(vec![peer]);
let first_seen = Instant::now();
let grace = Duration::from_secs(60);
let _ = state.observe_bootstrap_claim(peer, first_seen, grace);
assert_eq!(
state.observe_bootstrap_claim(peer, first_seen + grace + Duration::from_secs(1), grace),
BootstrapClaimObservation::PastGrace { first_seen }
);
}
#[test]
fn bootstrap_state_initial_not_drained() {
let state = BootstrapState::new();
assert!(
!state.is_drained(),
"initial state must not be drained before bootstrap begins"
);
}
#[test]
fn bootstrap_state_pending_requests_block_drain() {
let mut state = BootstrapState::new();
state.pending_peer_requests = 3;
assert!(
!state.is_drained(),
"pending peer requests should block drain"
);
}
#[test]
fn bootstrap_state_pending_keys_block_drain() {
let mut state = BootstrapState::new();
state.pending_keys.insert([42u8; 32]);
assert!(!state.is_drained(), "pending keys should block drain");
}
#[test]
fn bootstrap_state_explicit_drained_overrides() {
let mut state = BootstrapState::new();
state.pending_peer_requests = 5;
state.pending_keys.insert([99u8; 32]);
state.drained = true;
assert!(
state.is_drained(),
"explicit drained flag should override pending counts"
);
}
#[test]
fn bootstrap_state_requires_explicit_drain() {
let mut state = BootstrapState::new();
state.pending_peer_requests = 2;
state.pending_keys.insert([1u8; 32]);
state.pending_peer_requests = 0;
state.pending_keys.clear();
assert!(
!state.is_drained(),
"clearing counters alone must not drain — requires check_bootstrap_drained"
);
state.drained = true;
assert!(state.is_drained(), "explicit flag should drain");
}
#[test]
fn bootstrap_state_default_matches_new() {
let from_new = BootstrapState::new();
let from_default = BootstrapState::default();
assert_eq!(from_new.drained, from_default.drained);
assert_eq!(
from_new.pending_peer_requests,
from_default.pending_peer_requests
);
assert_eq!(from_new.pending_keys, from_default.pending_keys);
}
#[test]
fn bootstrap_drain_requires_empty_pending_keys() {
let key_a: XorName = [0xA0; 32];
let key_b: XorName = [0xB0; 32];
let key_c: XorName = [0xC0; 32];
let mut state = BootstrapState::new();
state.pending_peer_requests = 0; state.pending_keys = std::iter::once(key_a)
.chain(std::iter::once(key_b))
.chain(std::iter::once(key_c))
.collect();
assert!(
!state.is_drained(),
"should NOT be drained while pending_keys still has entries"
);
state.pending_keys.remove(&key_a);
assert!(!state.is_drained(), "still not drained with 2 pending keys");
state.pending_keys.remove(&key_b);
assert!(!state.is_drained(), "still not drained with 1 pending key");
state.pending_keys.remove(&key_c);
assert!(
!state.is_drained(),
"removing all keys is necessary but not sufficient — needs explicit drain"
);
state.drained = true;
assert!(state.is_drained(), "explicit drain flag should finalize");
}
#[test]
fn verification_state_terminal_variants() {
let terminal_states = [
VerificationState::QuorumAbandoned,
VerificationState::FetchAbandoned,
VerificationState::Stored,
VerificationState::Idle,
];
for (i, a) in terminal_states.iter().enumerate() {
for (j, b) in terminal_states.iter().enumerate() {
if i != j {
assert_ne!(
a, b,
"terminal states at indices {i} and {j} must be distinct"
);
}
}
}
let non_terminal_states = [
VerificationState::OfferReceived,
VerificationState::PendingVerify,
VerificationState::QuorumVerified,
VerificationState::PaidListVerified,
VerificationState::QueuedForFetch,
VerificationState::Fetching,
VerificationState::FetchRetryable,
VerificationState::QuorumFailed,
VerificationState::QuorumInconclusive,
];
for terminal in &terminal_states {
for non_terminal in &non_terminal_states {
assert_ne!(
terminal, non_terminal,
"terminal state {terminal:?} must not equal non-terminal state {non_terminal:?}"
);
}
}
}
#[test]
fn repair_opportunity_requires_both_sync_and_cycle() {
let synced_no_cycle = PeerSyncRecord {
last_sync: Some(
Instant::now()
.checked_sub(std::time::Duration::from_secs(2))
.unwrap_or_else(Instant::now),
),
cycles_since_sync: 0,
};
assert!(
!synced_no_cycle.has_repair_opportunity(),
"synced with zero subsequent cycles should NOT have repair opportunity"
);
let never_synced = PeerSyncRecord {
last_sync: None,
cycles_since_sync: 5,
};
assert!(
!never_synced.has_repair_opportunity(),
"never-synced peer should NOT have repair opportunity regardless of cycles"
);
let ready = PeerSyncRecord {
last_sync: Some(
Instant::now()
.checked_sub(std::time::Duration::from_secs(5))
.unwrap_or_else(Instant::now),
),
cycles_since_sync: 1,
};
assert!(
ready.has_repair_opportunity(),
"synced peer with >= 1 cycle SHOULD have repair opportunity"
);
}
}