use proptest::prelude::*;
use proptest::test_runner::FileFailurePersistence;
use std::str::FromStr;
use troy::{Dec, ParseDecError};
fn config() -> ProptestConfig {
ProptestConfig {
failure_persistence: Some(Box::new(FileFailurePersistence::Direct(
"tests/fuzz.proptest-regressions",
))),
..ProptestConfig::default()
}
}
const MIN_RAW: i128 = Dec::MIN.into_raw();
const MAX_RAW: i128 = Dec::MAX.into_raw();
const ONE_RAW: i128 = 1_000_000_000_000_000_000;
const HALF_MAX_RAW: i128 = MAX_RAW / 2;
fn finite() -> impl Strategy<Value = Dec> {
(MIN_RAW..=MAX_RAW).prop_map(Dec::from_raw)
}
fn any_dec() -> impl Strategy<Value = Dec> {
prop_oneof![
49 => finite(),
1 => Just(Dec::NAN),
]
}
fn modest() -> impl Strategy<Value = Dec> {
(-9_000_000_000_000_000_000_i128..=9_000_000_000_000_000_000).prop_map(Dec::from_raw)
}
fn reasonable_f64() -> impl Strategy<Value = f64> {
prop_oneof![
3 => -1e9_f64..1e9,
2 => -1.0_f64..1.0,
2 => -1e-6_f64..1e-6,
1 => -1e18_f64..1e18,
]
}
fn decimal_shaped_text() -> impl Strategy<Value = String> {
proptest::collection::vec(
prop_oneof![
10 => prop::char::range('0', '9'),
2 => Just('.'),
2 => Just('_'),
1 => Just('e'),
1 => Just('E'),
1 => Just('-'),
1 => Just('+'),
1 => Just(' '),
],
0..40,
)
.prop_map(|chars| chars.into_iter().collect())
}
fn equivalent_spellings(raw: i128) -> Vec<String> {
let sign = if raw < 0 { "-" } else { "" };
let digits = raw.unsigned_abs().to_string();
let mut forms = Vec::new();
for point in 0..=Dec::SCALE as usize {
let padded = format!("{digits:0>width$}", width = point + 1);
let split = padded.len() - point;
let exponent = Dec::SCALE as i32 - point as i32;
forms.push(format!(
"{sign}{}.{}e-{exponent}",
&padded[..split],
&padded[split..]
));
}
let plain = Dec::from_raw(raw).to_string();
forms.push(plain.replace('e', "E"));
forms.push(format!("{sign}000{}", plain.trim_start_matches('-')));
forms.push(format!(" {plain}"));
if raw > 0 {
forms.push(format!("+{plain}"));
}
if let Some(point) = plain.find('.') {
let spare = Dec::SCALE as usize - (plain.len() - point - 1);
forms.push(format!("{plain}{}", "0".repeat(spare)));
}
forms
}
proptest! {
#![proptest_config(config())]
#[test]
fn test_parsing_arbitrary_bytes_never_panics(bytes in proptest::collection::vec(any::<u8>(), 0..64)) {
let text = String::from_utf8_lossy(&bytes);
let _ = Dec::from_str(&text);
let _ = Dec::parse_const(&text);
}
#[test]
fn test_parsing_decimal_shaped_text_never_panics(text in decimal_shaped_text()) {
let _ = Dec::from_str(&text);
}
#[test]
fn test_a_parsed_value_is_never_nan(text in decimal_shaped_text()) {
if let Ok(value) = Dec::from_str(&text) {
prop_assert!(value.is_finite(), "{text:?} parsed to NaN");
}
}
#[test]
fn test_the_const_parser_agrees_with_from_str(text in decimal_shaped_text()) {
prop_assert_eq!(Dec::parse_const(&text), Dec::from_str(&text).ok());
}
#[test]
fn test_equivalent_spellings_parse_to_the_same_value(raw in MIN_RAW..=MAX_RAW) {
let expected = Dec::from_raw(raw);
for form in equivalent_spellings(raw) {
prop_assert_eq!(
Dec::from_str(&form),
Ok(expected),
"spelling {:?} of {}",
form,
expected
);
}
}
#[test]
fn test_text_past_the_range_overflows(excess in 1_i8..=127) {
for (limit, sign) in [(MAX_RAW, ""), (MIN_RAW, "-")] {
let text = format!("{sign}{}{}", limit.unsigned_abs(), excess % 10);
prop_assert_eq!(
Dec::from_str(&text),
Err(ParseDecError::Overflow),
"{}",
text
);
}
}
#[test]
fn test_excess_precision_rounds_half_away_from_zero(
raw in -HALF_MAX_RAW..=HALF_MAX_RAW,
tail in 0_u32..1_000,
) {
let magnitude = raw.unsigned_abs();
let sign = if raw < 0 { "-" } else { "" };
let text = format!(
"{sign}{}.{:018}{tail:03}",
magnitude / ONE_RAW as u128,
magnitude % ONE_RAW as u128,
);
let parsed = Dec::from_str(&text).expect("in range, with three digits added");
let expected = match (tail >= 500, raw < 0) {
(true, true) => -1,
(true, false) => 1,
(false, _) => 0,
};
prop_assert_eq!(
parsed.into_raw() - raw,
expected,
"{} parsed to {}",
text,
parsed
);
}
}
proptest! {
#![proptest_config(config())]
#[test]
fn test_every_finite_value_round_trips_through_its_text(value in finite()) {
let text = value.to_string();
prop_assert_eq!(Dec::from_str(&text), Ok(value), "text {}", text);
}
#[test]
fn test_the_rendering_is_canonical(value in finite()) {
let text = value.to_string();
prop_assert!(!text.ends_with('.'), "{text} ends with a bare point");
if let Some((integer, fraction)) = text.split_once('.') {
prop_assert!(!fraction.ends_with('0'), "{text} has a trailing zero");
prop_assert!(!fraction.is_empty(), "{text} has an empty fraction");
prop_assert!(
fraction.len() <= Dec::SCALE as usize,
"{text} carries more than the native scale"
);
prop_assert!(!integer.is_empty(), "{text} has no integer part");
}
let magnitude = text.trim_start_matches('-');
prop_assert!(
!magnitude.starts_with('0') || magnitude.starts_with("0."),
"{text} has a leading zero"
);
prop_assert_eq!(text.starts_with('-'), value.is_sign_negative());
}
#[test]
fn test_rendering_preserves_order_within_a_sign(a in finite(), b in finite()) {
let (a, b) = (a.abs(), b.abs());
let (left, right) = (a.to_string(), b.to_string());
let key = |text: &str| {
let integer = text.split_once('.').map_or(text.len(), |(head, _)| head.len());
(integer, text.to_string())
};
prop_assert_eq!(a.cmp(&b), key(&left).cmp(&key(&right)), "{} vs {}", left, right);
}
}
#[test]
fn test_precision_past_the_mantissa_rounds_rather_than_failing() {
let one = format!("1.{}1", "0".repeat(41));
assert_eq!(Dec::from_str(&one), Ok(Dec::ONE));
assert_eq!(Dec::from_str(&format!("{}1", Dec::MAX)), Ok(Dec::MAX));
assert_eq!(Dec::from_str(&format!("{}4", Dec::MIN)), Ok(Dec::MIN));
assert_eq!(
Dec::from_str(&format!("{}5", Dec::MAX)),
Err(ParseDecError::Overflow),
"rounding up here really does leave the range"
);
assert_eq!(
Dec::from_str("1701411834604692317316873037158841057270"),
Err(ParseDecError::Overflow)
);
let tiny = format!("0.{}1", "0".repeat(41));
assert_eq!(Dec::from_str(&tiny), Ok(Dec::ZERO));
}
#[test]
fn test_nan_renders_but_does_not_round_trip() {
assert_eq!(Dec::NAN.to_string(), "NaN");
assert_eq!(format!("{:?}", Dec::NAN), "NaN");
assert_eq!(Dec::from_str("NaN"), Err(ParseDecError::InvalidDigit));
assert_eq!(Dec::parse_const("NaN"), None);
}
proptest! {
#![proptest_config(config())]
#[test]
fn test_round_to_step_matches_round_dp_on_every_power_of_ten(value in any_dec()) {
for dp in 0..=Dec::SCALE {
let step = Dec::from_raw(10_i128.pow(Dec::SCALE - dp));
prop_assert_eq!(
value.round_to_step(step),
value.round_dp(dp),
"value {} at {} places",
value,
dp
);
}
}
#[test]
fn test_rounding_is_idempotent(value in any_dec(), dp in 0_u32..=Dec::SCALE) {
let once = value.round_dp(dp);
prop_assert_eq!(once.round_dp(dp), once, "value {} at {} places", value, dp);
}
#[test]
fn test_rounding_moves_by_at_most_half_a_step(value in finite(), dp in 0_u32..=Dec::SCALE) {
let rounded = value.round_dp(dp);
prop_assume!(rounded.is_finite());
let step = 10_i128.pow(Dec::SCALE - dp);
let drift = rounded.into_raw() - value.into_raw();
prop_assert!(
drift.abs() * 2 <= step,
"{value} rounded to {dp} places moved {drift}, past half of {step}"
);
}
#[test]
fn test_a_rounded_value_is_a_multiple_of_its_step(
raw in MIN_RAW / 2..=MAX_RAW / 2,
step in 1_i128..=10_i128.pow(24),
) {
let (value, step) = (Dec::from_raw(raw), Dec::from_raw(step));
let rounded = value.round_to_step(step);
prop_assert!(rounded.is_finite(), "{value} to a step of {step} overflowed");
prop_assert_eq!(
rounded.into_raw() % step.into_raw(),
0,
"{} is not a multiple of {}",
rounded,
step
);
}
#[test]
fn test_rounding_never_flips_the_sign(value in finite(), dp in 0_u32..=Dec::SCALE) {
let rounded = value.round_dp(dp);
prop_assume!(rounded.is_finite());
if !rounded.is_zero() {
prop_assert_eq!(
rounded.is_sign_negative(),
value.is_sign_negative(),
"{} rounded to {} at {} places",
value,
rounded,
dp
);
}
}
#[test]
fn test_floor_and_ceil_bracket_the_value(value in modest()) {
let (floor, ceil, trunc) = (value.floor(), value.ceil(), value.trunc());
prop_assert!(floor <= value, "floor {floor} above {value}");
prop_assert!(ceil >= value, "ceil {ceil} below {value}");
prop_assert!(trunc >= floor && trunc <= ceil, "trunc {trunc} outside [{floor}, {ceil}]");
for whole in [floor, ceil, trunc] {
prop_assert_eq!(whole.into_raw() % ONE_RAW, 0, "{} is not whole", whole);
}
let span = ceil.into_raw() - floor.into_raw();
prop_assert_eq!(span, if value.into_raw() % ONE_RAW == 0 { 0 } else { ONE_RAW });
}
}
proptest! {
#![proptest_config(config())]
#[test]
fn test_to_f64_matches_the_standard_library(value in finite()) {
let reference: f64 = value.to_string().parse().expect("a rendered decimal");
let actual = value.to_f64();
let ulps = (actual.to_bits() as i64).abs_diff(reference.to_bits() as i64);
prop_assert!(
ulps <= 1,
"{value} converted to {actual:e}, {ulps} ulps from {reference:e}"
);
}
#[test]
fn test_to_f64_is_monotonic(a in finite(), b in finite()) {
if a < b {
prop_assert!(a.to_f64() <= b.to_f64(), "{a} -> {:e} not below {b} -> {:e}", a.to_f64(), b.to_f64());
}
}
#[test]
fn test_a_value_from_an_f64_returns_to_it(original in reasonable_f64()) {
if let Some(value) = Dec::from_f64(original) {
let back = value.to_f64();
let tolerance = 5e-16 + original.abs() * 5e-16;
prop_assert!(
(back - original).abs() <= tolerance,
"{original:e} became {value} and returned as {back:e}"
);
}
}
#[test]
fn test_from_f64_never_yields_nan(bits in any::<u64>()) {
if let Some(value) = Dec::from_f64(f64::from_bits(bits)) {
prop_assert!(value.is_finite(), "{:e} became NaN", f64::from_bits(bits));
}
}
#[test]
fn test_from_f64_round_is_stable(original in reasonable_f64(), dp in 0_u32..=Dec::SCALE) {
if let Some(value) = Dec::from_f64_round(original, dp) {
prop_assert_eq!(value.round_dp(dp), value, "{:e} at {} places", original, dp);
}
}
}
macro_rules! operator_pairs {
($a:expr, $b:expr, $body:expr) => {{
let checked: [(&str, Dec, Option<Dec>); 4] = [
("add", $a + $b, $a.checked_add($b)),
("sub", $a - $b, $a.checked_sub($b)),
("mul", $a * $b, $a.checked_mul($b)),
("div", $a / $b, $a.checked_div($b)),
];
#[allow(clippy::redundant_closure_call)]
for (name, operator, checked) in checked {
$body(name, operator, checked)?;
}
}};
}
proptest! {
#![proptest_config(config())]
#[test]
fn test_operators_and_their_checked_counterparts_agree(a in any_dec(), b in any_dec()) {
operator_pairs!(a, b, |name, operator: Dec, checked: Option<Dec>| {
match checked {
Some(value) => prop_assert_eq!(operator, value, "{} of {} and {}", name, a, b),
None => prop_assert!(operator.is_nan(), "{} of {} and {} kept {}", name, a, b, operator),
}
Ok(())
});
}
#[test]
fn test_nan_propagates_through_everything(value in finite(), dp in 0_u32..=Dec::SCALE) {
for (left, right) in [(Dec::NAN, value), (value, Dec::NAN), (Dec::NAN, Dec::NAN)] {
prop_assert!((left + right).is_nan(), "add kept {}", left + right);
prop_assert!((left - right).is_nan(), "sub kept {}", left - right);
prop_assert!((left * right).is_nan(), "mul kept {}", left * right);
prop_assert!((left / right).is_nan(), "div kept {}", left / right);
prop_assert!(left.saturating_add(right).is_nan(), "saturating_add kept a value");
prop_assert!(left.saturating_sub(right).is_nan(), "saturating_sub kept a value");
prop_assert!(left.saturating_mul(right).is_nan(), "saturating_mul kept a value");
prop_assert!(left.saturating_div(right).is_nan(), "saturating_div kept a value");
prop_assert!(left.midpoint(right).is_nan(), "midpoint kept a value");
prop_assert!(left.round_to_step(right).is_nan(), "round_to_step kept a value");
}
for unary in [
Dec::NAN.abs(),
Dec::NAN.signum(),
Dec::NAN.floor(),
Dec::NAN.ceil(),
Dec::NAN.trunc(),
Dec::NAN.round_dp(dp),
-Dec::NAN,
] {
prop_assert!(unary.is_nan(), "a unary operation recovered {}", unary);
}
prop_assert!(!Dec::NAN.is_zero());
prop_assert!(!Dec::NAN.is_sign_negative());
prop_assert!(!Dec::NAN.is_sign_positive());
prop_assert!(!Dec::NAN.is_finite());
prop_assert_eq!(Dec::NAN.to_f64().is_nan(), true);
}
#[test]
fn test_saturating_operations_clamp_rather_than_wrap(a in finite(), b in finite()) {
let saturating: [(&str, Dec, Option<Dec>); 4] = [
("add", a.saturating_add(b), a.checked_add(b)),
("sub", a.saturating_sub(b), a.checked_sub(b)),
("mul", a.saturating_mul(b), a.checked_mul(b)),
("div", a.saturating_div(b), a.checked_div(b)),
];
for (name, actual, exact) in saturating {
if name == "div" && b.is_zero() {
prop_assert!(actual.is_nan(), "div by zero gave {actual}");
continue;
}
prop_assert!(actual.is_finite(), "saturating_{name} of {a} and {b} gave NaN");
match exact {
Some(value) => prop_assert_eq!(actual, value, "saturating_{} of {} and {}", name, a, b),
None => prop_assert!(
actual == Dec::MAX || actual == Dec::MIN,
"saturating_{name} of {a} and {b} gave {actual} rather than a limit"
),
}
}
}
#[test]
fn test_addition_and_subtraction_invert(a in finite(), b in finite()) {
if let Some(sum) = a.checked_add(b) {
prop_assert_eq!(sum.checked_sub(b), Some(a), "{} + {} - {}", a, b, b);
prop_assert_eq!(sum, b + a, "addition is not commutative for {} and {}", a, b);
}
}
#[test]
fn test_the_midpoint_lies_between_its_ends(a in finite(), b in finite()) {
let mid = a.midpoint(b);
prop_assert!(mid.is_finite(), "midpoint of {a} and {b} overflowed");
prop_assert!(mid >= a.min(b) && mid <= a.max(b), "{mid} outside [{a}, {b}]");
}
#[test]
fn test_negation_and_abs_are_total(value in finite()) {
let negated = -value;
prop_assert!(negated.is_finite(), "negating {value} left the range");
prop_assert_eq!(-negated, value, "negating {} twice", value);
prop_assert!(value.abs().is_finite(), "abs of {value} left the range");
prop_assert!(!value.abs().is_sign_negative(), "abs of {value} stayed negative");
prop_assert_eq!(value.abs(), if value.is_sign_negative() { negated } else { value });
}
#[test]
fn test_the_ordering_is_total_and_agrees_with_the_raw(values in proptest::collection::vec(any_dec(), 0..32)) {
let mut sorted = values.clone();
sorted.sort();
let mut raws: Vec<i128> = values.iter().map(|value| value.into_raw()).collect();
raws.sort_unstable();
let sorted_raws: Vec<i128> = sorted.iter().map(|value| value.into_raw()).collect();
prop_assert_eq!(sorted_raws, raws);
if values.contains(&Dec::NAN) {
prop_assert_eq!(sorted.first(), Some(&Dec::NAN));
}
}
}