#[cfg(test)]
mod tests {
use crate::types::*;
use proptest::prelude::*;
fn package_name_strategy() -> impl Strategy<Value = String> {
"[a-z][a-z0-9_-]{0,19}".prop_map(|s| s.to_lowercase())
}
proptest! {
#[test]
fn package_state_roundtrip(
state in prop_oneof![
Just(PackageState::Pending),
Just(PackageState::Uploaded),
Just(PackageState::Published),
Just(PackageState::Skipped { reason: "test".to_string() }),
Just(PackageState::Failed { class: ErrorClass::Retryable, message: "error".to_string() }),
Just(PackageState::Ambiguous { message: "maybe".to_string() }),
]
) {
let json = serde_json::to_string(&state).unwrap();
let parsed: PackageState = serde_json::from_str(&json).unwrap();
assert_eq!(state, parsed);
}
#[test]
fn registry_normalization_idempotent(name in package_name_strategy()) {
let normalized1 = name.to_lowercase().replace('-', "_");
let normalized2 = normalized1.to_lowercase().replace('-', "_");
assert_eq!(normalized1, normalized2);
}
#[test]
fn delay_bounded_no_jitter(
base_ms in 1u64..10000,
max_ms in 100u64..300000,
attempt in 1u32..100,
) {
use std::time::Duration;
use crate::retry::{calculate_delay, RetryStrategyConfig, RetryStrategyType};
let base_delay = Duration::from_millis(base_ms.min(max_ms));
let max_delay = Duration::from_millis(max_ms);
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
max_attempts: 100,
base_delay,
max_delay,
jitter: 0.0, };
let delay = calculate_delay(&config, attempt);
prop_assert!(delay <= max_delay, "Delay {} should not exceed max {}", delay.as_millis(), max_delay.as_millis());
}
#[test]
fn error_class_roundtrip(
class in prop_oneof![
Just(ErrorClass::Retryable),
Just(ErrorClass::Permanent),
Just(ErrorClass::Ambiguous),
]
) {
let json = serde_json::to_string(&class).unwrap();
let parsed: ErrorClass = serde_json::from_str(&json).unwrap();
assert_eq!(class, parsed);
}
}
}
#[cfg(test)]
mod state_machine_tests {
use crate::types::*;
fn valid_transitions(from: &PackageState) -> Vec<PackageState> {
match from {
PackageState::Pending => vec![
PackageState::Uploaded,
PackageState::Published,
PackageState::Skipped {
reason: String::new(),
},
PackageState::Failed {
class: ErrorClass::Retryable,
message: String::new(),
},
],
PackageState::Uploaded => vec![
PackageState::Published,
PackageState::Ambiguous {
message: String::new(),
},
PackageState::Failed {
class: ErrorClass::Permanent,
message: String::new(),
},
],
PackageState::Published => vec![], PackageState::Skipped { .. } => vec![], PackageState::Failed { .. } => vec![], PackageState::Ambiguous { .. } => vec![
PackageState::Published,
PackageState::Failed {
class: ErrorClass::Permanent,
message: String::new(),
},
],
}
}
#[test]
fn test_pending_transitions() {
let pending = PackageState::Pending;
let valid = valid_transitions(&pending);
assert!(valid.contains(&PackageState::Uploaded));
assert!(valid.contains(&PackageState::Published));
assert!(!valid.contains(&PackageState::Pending)); }
#[test]
fn test_published_is_terminal() {
let published = PackageState::Published;
assert!(valid_transitions(&published).is_empty());
}
#[test]
fn test_failed_is_terminal() {
let failed = PackageState::Failed {
class: ErrorClass::Retryable,
message: "error".into(),
};
assert!(valid_transitions(&failed).is_empty());
}
#[test]
fn test_skipped_is_terminal() {
let skipped = PackageState::Skipped {
reason: "already published".into(),
};
assert!(valid_transitions(&skipped).is_empty());
}
#[test]
fn test_ambiguous_can_resolve() {
let ambiguous = PackageState::Ambiguous {
message: "unclear".into(),
};
let valid = valid_transitions(&ambiguous);
assert!(valid.contains(&PackageState::Published));
}
}
#[cfg(test)]
mod topo_invariant_tests {
use crate::types::*;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
fn verify_topo_order(
packages: &[PlannedPackage],
dependencies: &BTreeMap<String, BTreeSet<String>>,
) -> bool {
let positions: BTreeMap<String, usize> = packages
.iter()
.enumerate()
.map(|(i, p)| (p.name.clone(), i))
.collect();
for pkg in packages {
if let Some(deps) = dependencies.get(&pkg.name) {
let pkg_pos = positions[&pkg.name];
for dep in deps {
if let Some(&dep_pos) = positions.get(dep)
&& dep_pos >= pkg_pos
{
return false; }
}
}
}
true
}
fn make_pkg(name: &str, version: &str) -> PlannedPackage {
PlannedPackage {
name: name.to_string(),
version: version.to_string(),
manifest_path: PathBuf::from(format!("crates/{}/Cargo.toml", name)),
}
}
#[test]
fn test_topo_simple_chain() {
let packages = vec![
make_pkg("core", "1.0.0"),
make_pkg("utils", "1.0.0"),
make_pkg("app", "1.0.0"),
];
let mut deps = BTreeMap::new();
deps.insert("utils".into(), BTreeSet::from(["core".into()]));
deps.insert(
"app".into(),
BTreeSet::from(["utils".into(), "core".into()]),
);
assert!(verify_topo_order(&packages, &deps));
}
#[test]
fn test_topo_invalid_order_detected() {
let packages = vec![
make_pkg("app", "1.0.0"),
make_pkg("utils", "1.0.0"),
make_pkg("core", "1.0.0"),
];
let mut deps = BTreeMap::new();
deps.insert("app".into(), BTreeSet::from(["core".into()]));
assert!(!verify_topo_order(&packages, &deps));
}
#[test]
fn test_topo_independent_packages() {
let packages = vec![
make_pkg("alpha", "1.0.0"),
make_pkg("beta", "1.0.0"),
make_pkg("gamma", "1.0.0"),
];
let deps = BTreeMap::new(); assert!(verify_topo_order(&packages, &deps));
}
#[test]
fn test_topo_diamond_dependency() {
let packages = vec![
make_pkg("D", "1.0.0"),
make_pkg("B", "1.0.0"),
make_pkg("C", "1.0.0"),
make_pkg("A", "1.0.0"),
];
let mut deps = BTreeMap::new();
deps.insert("B".into(), BTreeSet::from(["D".into()]));
deps.insert("C".into(), BTreeSet::from(["D".into()]));
deps.insert("A".into(), BTreeSet::from(["B".into(), "C".into()]));
assert!(verify_topo_order(&packages, &deps));
}
}