lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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:
Mcrates/core/src/money.rs | 18+++++++++++++++---
Mcrates/core/src/unit.rs | 28++++++++++++++++++++--------
Mcrates/core/tests/currency.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/core/tests/decimal.rs | 20++++++++++++++++++++
Mcrates/core/tests/money.rs | 7+++++++
Mcrates/core/tests/quantity.rs | 15+++++++++++++++
Mcrates/core/tests/quantity_price.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/core/tests/serde.rs | 9+++++++++
Mcrates/core/tests/unit.rs | 30++++++++++++++++++++++++++++++
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]