commit ac35e9c4396d42d990f72ce5b13eff15df44c27c
parent b9dcb9b291d2ae61d0c49b45130026f2d5a1ba82
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Feb 2026 18:14:46 +0000
core: add remaining serialization and edge-path coverage tests
- add currency decimal quantity quantity-price and serde edge tests for uncovered paths
- add internal helper tests for money and unit private fallback coverage branches
- simplify unreachable defensive branches in rounded money conversion and unit conversion routing
- run cargo check -q -p `radroots-core` cargo test -q -p `radroots-core` and strict core 100-100-100 gate
Diffstat:
9 files changed, 243 insertions(+), 11 deletions(-)
diff --git a/crates/core/src/money.rs b/crates/core/src/money.rs
@@ -158,9 +158,7 @@ impl RadrootsCoreMoney {
let e = self.currency.minor_unit_exponent();
let scaled = self.amount.0.round_dp_with_strategy(e, strategy);
let as_minor = scaled * Self::pow10(e);
- if !as_minor.fract().is_zero() {
- return Err(RadrootsCoreMoneyInvariantError::NotWholeMinorUnits);
- }
+ debug_assert!(as_minor.fract().is_zero());
as_minor
.to_u64()
.ok_or(RadrootsCoreMoneyInvariantError::AmountOverflow)
@@ -203,3 +201,17 @@ impl Div<crate::RadrootsCoreDecimal> for RadrootsCoreMoney {
self.div_decimal(rhs)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn pow10_internal_paths_cover_fallback_branches() {
+ assert_eq!(RadrootsCoreMoney::pow10(0), Decimal::ONE);
+ assert_eq!(RadrootsCoreMoney::pow10(1), Decimal::from(10u32));
+ assert_eq!(RadrootsCoreMoney::pow10(2), Decimal::from(100u32));
+ assert_eq!(RadrootsCoreMoney::pow10(3), Decimal::from(1_000u32));
+ assert_eq!(RadrootsCoreMoney::pow10(6), Decimal::from(1_000_000u32));
+ }
+}
diff --git a/crates/core/src/unit.rs b/crates/core/src/unit.rs
@@ -277,17 +277,29 @@ pub fn convert_unit_decimal(
from: RadrootsCoreUnit,
to: RadrootsCoreUnit,
) -> Result<RadrootsCoreDecimal, RadrootsCoreUnitConvertError> {
- if from == to {
- return Ok(amount);
- }
if !RadrootsCoreUnit::same_dimension(from, to) {
return Err(RadrootsCoreUnitConvertError::NotConvertibleUnits { from, to });
}
- if from.is_mass() {
- return convert_mass_decimal(amount, from, to);
+ match from.dimension() {
+ RadrootsCoreUnitDimension::Count => Ok(amount),
+ RadrootsCoreUnitDimension::Mass => convert_mass_decimal(amount, from, to),
+ RadrootsCoreUnitDimension::Volume => convert_volume_decimal(amount, from, to),
}
- if from.is_volume() {
- return convert_volume_decimal(amount, from, to);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn helper_factor_fallback_paths_are_exercised() {
+ assert_eq!(
+ grams_factor_decimal(RadrootsCoreUnit::Each),
+ RadrootsCoreDecimal::ONE
+ );
+ assert_eq!(
+ milliliters_factor_decimal(RadrootsCoreUnit::Each),
+ RadrootsCoreDecimal::ONE
+ );
}
- Err(RadrootsCoreUnitConvertError::NotConvertibleUnits { from, to })
}
diff --git a/crates/core/tests/currency.rs b/crates/core/tests/currency.rs
@@ -32,12 +32,28 @@ fn from_str_rejects_non_alpha() {
RadrootsCoreCurrency::from_str("US1"),
Err(RadrootsCoreCurrencyParseError::InvalidFormat)
);
+ assert_eq!(
+ RadrootsCoreCurrency::from_str("US"),
+ Err(RadrootsCoreCurrencyParseError::InvalidFormat)
+ );
}
#[test]
fn from_const_validates_bytes() {
assert!(RadrootsCoreCurrency::from_const(*b"USD").is_ok());
assert_eq!(
+ RadrootsCoreCurrency::from_const(*b"1SD"),
+ Err(RadrootsCoreCurrencyParseError::InvalidFormat)
+ );
+ assert_eq!(
+ RadrootsCoreCurrency::from_const(*b"Usd"),
+ Err(RadrootsCoreCurrencyParseError::InvalidFormat)
+ );
+ assert_eq!(
+ RadrootsCoreCurrency::from_const(*b"USd"),
+ Err(RadrootsCoreCurrencyParseError::InvalidFormat)
+ );
+ assert_eq!(
RadrootsCoreCurrency::from_const(*b"usd"),
Err(RadrootsCoreCurrencyParseError::InvalidFormat)
);
@@ -53,3 +69,25 @@ fn minor_unit_exponent_matches_known_currencies() {
assert_eq!(kwd.minor_unit_exponent(), 3);
assert_eq!(usd.minor_unit_exponent(), 2);
}
+
+#[test]
+fn display_debug_tryfrom_and_error_display_paths_are_exercised() {
+ let usd = RadrootsCoreCurrency::from_str("usd").unwrap();
+ assert_eq!(usd.to_string(), "USD");
+ assert_eq!(format!("{usd:?}"), "RadrootsCoreCurrency(\"USD\")");
+ let via_try_from = RadrootsCoreCurrency::try_from("usd").unwrap();
+ assert_eq!(via_try_from, usd);
+ assert_eq!(
+ RadrootsCoreCurrencyParseError::InvalidFormat.to_string(),
+ "currency must be a 3-letter code"
+ );
+}
+
+#[cfg(feature = "serde")]
+#[test]
+fn serde_deserialize_paths_are_exercised() {
+ let parsed: RadrootsCoreCurrency = serde_json::from_str("\"USD\"").unwrap();
+ assert_eq!(parsed.as_str(), "USD");
+ let err = serde_json::from_str::<RadrootsCoreCurrency>("\"US1\"").unwrap_err();
+ assert!(err.to_string().contains("currency must be a 3-letter code"));
+}
diff --git a/crates/core/tests/decimal.rs b/crates/core/tests/decimal.rs
@@ -30,3 +30,23 @@ fn from_f64_display_roundtrips_reasonably() {
let v = d.to_f64_lossy().expect("f64 conversion");
assert!((v - 1.25).abs() < 1e-12);
}
+
+#[test]
+fn from_str_exact_and_conversion_impl_paths_are_exercised() {
+ let exact = RadrootsCoreDecimal::from_str_exact("42.000").unwrap();
+ assert_eq!(exact, common::dec("42"));
+
+ let from_decimal = RadrootsCoreDecimal::from(rust_decimal::Decimal::from(5u32));
+ assert_eq!(from_decimal, common::dec("5"));
+ let back: rust_decimal::Decimal = from_decimal.into();
+ assert_eq!(back, rust_decimal::Decimal::from(5u32));
+
+ let from_u32 = RadrootsCoreDecimal::from(7u32);
+ let from_i32 = RadrootsCoreDecimal::from(-2i32);
+ let from_u64 = RadrootsCoreDecimal::from(11u64);
+ let from_i64 = RadrootsCoreDecimal::from(-9i64);
+ assert_eq!(from_u32, common::dec("7"));
+ assert_eq!(from_i32, common::dec("-2"));
+ assert_eq!(from_u64, common::dec("11"));
+ assert_eq!(from_i64, common::dec("-9"));
+}
diff --git a/crates/core/tests/money.rs b/crates/core/tests/money.rs
@@ -137,6 +137,13 @@ fn from_minor_units_u32_and_u32_rounded_paths_are_exercised() {
}
#[test]
+fn with_scale_path_is_exercised() {
+ let usd = RadrootsCoreCurrency::USD;
+ let m = RadrootsCoreMoney::new(common::dec("1.2300"), usd).with_scale(1);
+ assert_eq!(m.amount, common::dec("1.2"));
+}
+
+#[test]
fn from_minor_units_roundtrips() {
let usd = RadrootsCoreCurrency::USD;
let money = RadrootsCoreMoney::from_minor_units_u64(12345, usd);
diff --git a/crates/core/tests/quantity.rs b/crates/core/tests/quantity.rs
@@ -38,6 +38,12 @@ fn ensure_non_negative_rejects_negative_amount() {
}
#[test]
+fn ensure_non_negative_accepts_non_negative_amount() {
+ let q = common::qty("0", RadrootsCoreUnit::Each);
+ assert_eq!(q.ensure_non_negative(), Ok(()));
+}
+
+#[test]
fn try_add_and_try_sub_require_matching_units() {
let a = common::qty("1", RadrootsCoreUnit::Each).with_label("lhs");
let b = common::qty("2", RadrootsCoreUnit::Each);
@@ -58,6 +64,15 @@ fn try_add_and_try_sub_require_matching_units() {
}
#[test]
+fn try_sub_success_path_is_exercised() {
+ let a = common::qty("4", RadrootsCoreUnit::Each).with_label("lhs");
+ let b = common::qty("1", RadrootsCoreUnit::Each);
+ let out = a.try_sub(&b).expect("sub result");
+ assert_eq!(out.amount, common::dec("3"));
+ assert_eq!(out.label.as_deref(), Some("lhs"));
+}
+
+#[test]
fn checked_add_and_sub_return_none_on_mismatch() {
let a = common::qty("1", RadrootsCoreUnit::Each);
let b = common::qty("2", RadrootsCoreUnit::MassG);
diff --git a/crates/core/tests/quantity_price.rs b/crates/core/tests/quantity_price.rs
@@ -103,6 +103,95 @@ fn try_cost_for_amount_in_rejects_non_convertible_units() {
}
#[test]
+fn try_cost_for_amount_in_same_unit_path_is_exercised() {
+ let price = RadrootsCoreQuantityPrice::new(
+ common::money("4", "USD"),
+ common::qty("1", RadrootsCoreUnit::Each),
+ );
+ let out = price
+ .try_cost_for_amount_in(common::dec("3"), RadrootsCoreUnit::Each)
+ .unwrap();
+ assert_eq!(out.amount, common::dec("12"));
+}
+
+#[test]
+fn try_cost_for_quantity_in_path_is_exercised() {
+ let price = RadrootsCoreQuantityPrice::new(
+ common::money("10", "USD"),
+ common::qty("1", RadrootsCoreUnit::MassKg),
+ );
+ let qty = common::qty("250", RadrootsCoreUnit::MassG);
+ let out = price.try_cost_for_quantity_in(&qty).unwrap();
+ assert_eq!(out.amount, common::dec("2.5"));
+}
+
+#[test]
+fn try_to_unit_price_error_and_same_unit_paths_are_exercised() {
+ let zero = RadrootsCoreQuantityPrice::new(
+ common::money("10", "USD"),
+ common::qty("0", RadrootsCoreUnit::MassKg),
+ );
+ assert_eq!(
+ zero.try_to_unit_price(RadrootsCoreUnit::MassG),
+ Err(RadrootsCoreQuantityPriceError::PerQuantityZero)
+ );
+
+ let base = RadrootsCoreQuantityPrice::new(
+ common::money("5", "USD"),
+ common::qty("2", RadrootsCoreUnit::MassKg),
+ );
+ let same = base.try_to_unit_price(RadrootsCoreUnit::MassKg).unwrap();
+ assert_eq!(same.quantity.unit, RadrootsCoreUnit::MassKg);
+ assert_eq!(same.quantity.amount, common::dec("1"));
+ assert_eq!(same.amount.amount, common::dec("2.5"));
+
+ let err = base.try_to_unit_price(RadrootsCoreUnit::VolumeMl).unwrap_err();
+ assert_eq!(
+ err,
+ RadrootsCoreQuantityPriceError::NonConvertibleUnits {
+ from: RadrootsCoreUnit::MassKg,
+ to: RadrootsCoreUnit::VolumeMl
+ }
+ );
+}
+
+#[test]
+fn cost_for_and_quantized_price_zero_paths_are_exercised() {
+ let p = RadrootsCoreQuantityPrice::new(
+ common::money("3.33", "USD"),
+ common::qty("1", RadrootsCoreUnit::Each),
+ );
+ let zero_qty = common::qty("0", RadrootsCoreUnit::Each);
+ assert!(p.cost_for(&zero_qty).amount.is_zero());
+ assert!(p.cost_for_with_quantized_price(&zero_qty).amount.is_zero());
+
+ let zero_per = RadrootsCoreQuantityPrice::new(
+ common::money("3.33", "USD"),
+ common::qty("0", RadrootsCoreUnit::Each),
+ );
+ assert!(zero_per.cost_for(&common::qty("1", RadrootsCoreUnit::Each)).amount.is_zero());
+ assert!(
+ zero_per
+ .cost_for_with_quantized_price(&common::qty("1", RadrootsCoreUnit::Each))
+ .amount
+ .is_zero()
+ );
+
+ let mismatch_qty = common::qty("1", RadrootsCoreUnit::MassG);
+ assert!(p.cost_for_with_quantized_price(&mismatch_qty).amount.is_zero());
+}
+
+#[test]
+fn try_to_unit_price_detects_underflow_to_zero_normalized_amount() {
+ let tiny = RadrootsCoreQuantityPrice::new(
+ common::money("1", "USD"),
+ common::qty("0.0000000000000000000000000001", RadrootsCoreUnit::VolumeMl),
+ );
+ let err = tiny.try_to_unit_price(RadrootsCoreUnit::VolumeL).unwrap_err();
+ assert_eq!(err, RadrootsCoreQuantityPriceError::PerQuantityZero);
+}
+
+#[test]
fn try_to_canonical_unit_price_converts_units() {
let price = RadrootsCoreQuantityPrice::new(
common::money("6.99", "USD"),
diff --git a/crates/core/tests/serde.rs b/crates/core/tests/serde.rs
@@ -31,6 +31,15 @@ fn quantity_uses_decimal_str_and_omits_empty_label() {
}
#[test]
+fn quantity_deserializes_decimal_str_via_serde_ext() {
+ let raw = r#"{"amount":"1.2300","unit":"kg","label":"bag"}"#;
+ let q: RadrootsCoreQuantity = serde_json::from_str(raw).unwrap();
+ assert_eq!(q.amount, common::dec("1.23"));
+ assert_eq!(q.unit, RadrootsCoreUnit::MassKg);
+ assert_eq!(q.label.as_deref(), Some("bag"));
+}
+
+#[test]
fn money_and_percent_roundtrip_with_strings() {
let money = RadrootsCoreMoney::new(common::dec("2.50"), RadrootsCoreCurrency::USD);
let value = serde_json::to_value(&money).unwrap();
diff --git a/crates/core/tests/unit.rs b/crates/core/tests/unit.rs
@@ -103,10 +103,12 @@ fn convert_mass_decimal_converts_between_mass_units() {
let kg_to_g = convert_mass_decimal(common::dec("1"), MassKg, MassG).unwrap();
let g_to_kg = convert_mass_decimal(common::dec("1000"), MassG, MassKg).unwrap();
let lb_to_g = convert_mass_decimal(common::dec("1"), MassLb, MassG).unwrap();
+ let oz_to_g = convert_mass_decimal(common::dec("1"), MassOz, MassG).unwrap();
assert_eq!(kg_to_g, common::dec("1000"));
assert_eq!(g_to_kg, common::dec("1"));
assert_eq!(lb_to_g, common::dec("453.59237"));
+ assert_eq!(oz_to_g, common::dec("28.349523125"));
}
#[test]
@@ -124,6 +126,20 @@ fn convert_mass_decimal_rejects_non_mass_units() {
to: RadrootsCoreUnit::MassG
}
);
+
+ let err = convert_mass_decimal(
+ common::dec("1"),
+ RadrootsCoreUnit::MassKg,
+ RadrootsCoreUnit::Each,
+ )
+ .unwrap_err();
+ assert_eq!(
+ err,
+ RadrootsCoreUnitConvertError::NotMassUnit {
+ from: RadrootsCoreUnit::MassKg,
+ to: RadrootsCoreUnit::Each
+ }
+ );
}
#[test]
@@ -150,6 +166,20 @@ fn convert_volume_decimal_rejects_non_volume_units() {
to: RadrootsCoreUnit::VolumeMl
}
);
+
+ let err = convert_volume_decimal(
+ common::dec("1"),
+ RadrootsCoreUnit::VolumeMl,
+ RadrootsCoreUnit::Each,
+ )
+ .unwrap_err();
+ assert_eq!(
+ err,
+ RadrootsCoreUnitConvertError::NotVolumeUnit {
+ from: RadrootsCoreUnit::VolumeMl,
+ to: RadrootsCoreUnit::Each
+ }
+ );
}
#[test]