use proptest::prelude::*;
use rand::SeedableRng;
use rand::rngs::StdRng;
#[derive(Debug, Clone)]
enum Term {
Die { neg: bool, count: u32, sides: u32 },
Modifier(i32),
}
fn term_strategy() -> impl Strategy<Value = Term> {
prop_oneof![
(any::<bool>(), 1u32..=20, 1u32..=100).prop_map(|(neg, count, sides)| Term::Die {
neg,
count,
sides
}),
(-1000i32..=1000).prop_map(Term::Modifier),
]
}
fn expr_strategy() -> impl Strategy<Value = (String, i64, i64)> {
prop::collection::vec(term_strategy(), 1..6).prop_map(|terms| {
let mut expr = String::new();
let (mut min, mut max) = (0i64, 0i64);
for (i, term) in terms.iter().enumerate() {
match *term {
Term::Die { neg, count, sides } => {
let (c, s) = (count as i64, sides as i64);
if neg {
expr.push_str(if i == 0 { "-" } else { " - " });
min -= c * s;
max -= c;
} else {
if i > 0 {
expr.push_str(" + ");
}
min += c;
max += c * s;
}
expr.push_str(&format!("{count}d{sides}"));
}
Term::Modifier(m) => {
let mag = (m as i64).abs();
if m < 0 {
expr.push_str(if i == 0 { "-" } else { " - " });
min -= mag;
max -= mag;
expr.push_str(&mag.to_string());
} else {
if i > 0 {
expr.push_str(" + ");
}
min += m as i64;
max += m as i64;
expr.push_str(&m.to_string());
}
}
}
}
(expr, min, max)
})
}
proptest! {
#[test]
fn never_panics_on_arbitrary_input(s in any::<String>()) {
let _ = d20::roll_dice(&s);
}
#[test]
fn total_is_within_theoretical_bounds((expr, min, max) in expr_strategy()) {
let mut rng = StdRng::seed_from_u64(0xD20_D1CE);
let roll = d20::roll_dice_with_rng(&expr, &mut rng)
.unwrap_or_else(|e| panic!("valid expr {expr:?} failed to parse: {e}"));
prop_assert!(
roll.total >= min && roll.total <= max,
"expr={expr:?} total={} expected within [{min}, {max}]",
roll.total,
);
}
#[test]
fn same_seed_produces_identical_rolls((expr, min, max) in expr_strategy(), seed in any::<u64>()) {
let a = d20::roll_dice_with_rng(&expr, &mut StdRng::seed_from_u64(seed)).unwrap();
let b = d20::roll_dice_with_rng(&expr, &mut StdRng::seed_from_u64(seed)).unwrap();
prop_assert_eq!(&a, &b);
prop_assert!(a.total >= min && a.total <= max);
}
}
#[test]
fn fixed_seed_is_reproducible() {
let roll =
|seed| d20::roll_dice_with_rng("3d6 + 2d10 - 4", &mut StdRng::seed_from_u64(seed)).unwrap();
assert_eq!(roll(7), roll(7));
let first = roll(7);
assert!(first.total >= (3 + 2 - 4) && first.total <= (18 + 20 - 4));
}