lib

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

commit bbab2a137b050748b66055c067cd785977d30090
parent 66ba26b430848ef63a8384ae7ff6cbd061984c75
Author: triesap <tyson@radroots.org>
Date:   Thu,  5 Mar 2026 02:12:16 +0000

core: close strict coverage gaps in unit conversions

- refactor unit mass and volume conversion matching to eliminate unreachable fallback branches
- add targeted serde and conversion tests to cover string-deserialize and conversion edge paths
- expand mass conversion tests to cover mass-to-oz and mass-to-lb target factors
- verify radroots-core passes cargo check, cargo test, and strict 100/100/100/100 coverage gate

Diffstat:
Mcrates/core/Cargo.toml | 3+++
Mcrates/core/src/unit.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/core/tests/currency.rs | 2++
Mcrates/core/tests/decimal.rs | 9+++++++++
Mcrates/core/tests/money.rs | 27+++++++++++++++++++++++++++
Mcrates/core/tests/quantity.rs | 40++++++++++++++++++++++++++++++++++++++++
Mcrates/core/tests/quantity_price.rs | 15+++++++++++++++
Mcrates/core/tests/serde.rs | 7+++++++
Mcrates/core/tests/unit.rs | 27+++++++++++++++++++++++++--
9 files changed, 185 insertions(+), 37 deletions(-)

diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml @@ -29,3 +29,6 @@ ts-rs = { workspace = true, optional = true } [dev-dependencies] serde_json = { workspace = true } rust_decimal = { workspace = true } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/core/src/unit.rs b/crates/core/src/unit.rs @@ -226,36 +226,32 @@ pub fn parse_volume_unit(s: &str) -> Result<RadrootsCoreUnit, RadrootsCoreUnitPa } #[inline] -fn grams_factor_decimal(u: RadrootsCoreUnit) -> RadrootsCoreDecimal { - match u { - RadrootsCoreUnit::MassG => RadrootsCoreDecimal::ONE, - RadrootsCoreUnit::MassKg => RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassOz => RadrootsCoreDecimal(dec!(28.349523125)), - RadrootsCoreUnit::MassLb => RadrootsCoreDecimal(dec!(453.59237)), - _ => RadrootsCoreDecimal::ONE, - } -} - -#[inline] -fn milliliters_factor_decimal(u: RadrootsCoreUnit) -> RadrootsCoreDecimal { - match u { - RadrootsCoreUnit::VolumeMl => RadrootsCoreDecimal::ONE, - RadrootsCoreUnit::VolumeL => RadrootsCoreDecimal::from(1000u32), - _ => RadrootsCoreDecimal::ONE, - } -} - -#[inline] pub fn convert_mass_decimal( amount: RadrootsCoreDecimal, from: RadrootsCoreUnit, to: RadrootsCoreUnit, ) -> Result<RadrootsCoreDecimal, RadrootsCoreUnitConvertError> { - if !from.is_mass() || !to.is_mass() { - return Err(RadrootsCoreUnitConvertError::NotMassUnit { from, to }); - } - let amount_g = amount * grams_factor_decimal(from); - Ok(amount_g / grams_factor_decimal(to)) + let amount_g = match from { + RadrootsCoreUnit::MassG => amount, + RadrootsCoreUnit::MassKg => amount * RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassOz => amount * RadrootsCoreDecimal(dec!(28.349523125)), + RadrootsCoreUnit::MassLb => amount * RadrootsCoreDecimal(dec!(453.59237)), + _ => { + return Err(RadrootsCoreUnitConvertError::NotMassUnit { from, to }); + } + }; + + let to_factor = match to { + RadrootsCoreUnit::MassG => RadrootsCoreDecimal::ONE, + RadrootsCoreUnit::MassKg => RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassOz => RadrootsCoreDecimal(dec!(28.349523125)), + RadrootsCoreUnit::MassLb => RadrootsCoreDecimal(dec!(453.59237)), + _ => { + return Err(RadrootsCoreUnitConvertError::NotMassUnit { from, to }); + } + }; + + Ok(amount_g / to_factor) } #[inline] @@ -264,11 +260,23 @@ pub fn convert_volume_decimal( from: RadrootsCoreUnit, to: RadrootsCoreUnit, ) -> Result<RadrootsCoreDecimal, RadrootsCoreUnitConvertError> { - if !from.is_volume() || !to.is_volume() { - return Err(RadrootsCoreUnitConvertError::NotVolumeUnit { from, to }); - } - let amount_ml = amount * milliliters_factor_decimal(from); - Ok(amount_ml / milliliters_factor_decimal(to)) + let amount_ml = match from { + RadrootsCoreUnit::VolumeMl => amount, + RadrootsCoreUnit::VolumeL => amount * RadrootsCoreDecimal::from(1000u32), + _ => { + return Err(RadrootsCoreUnitConvertError::NotVolumeUnit { from, to }); + } + }; + + let to_factor = match to { + RadrootsCoreUnit::VolumeMl => RadrootsCoreDecimal::ONE, + RadrootsCoreUnit::VolumeL => RadrootsCoreDecimal::from(1000u32), + _ => { + return Err(RadrootsCoreUnitConvertError::NotVolumeUnit { from, to }); + } + }; + + Ok(amount_ml / to_factor) } #[inline] @@ -292,14 +300,28 @@ mod tests { use super::*; #[test] - fn helper_factor_fallback_paths_are_exercised() { + fn convert_paths_cover_unit_branches() { assert_eq!( - grams_factor_decimal(RadrootsCoreUnit::Each), - RadrootsCoreDecimal::ONE + convert_mass_decimal( + RadrootsCoreDecimal::ONE, + RadrootsCoreUnit::Each, + RadrootsCoreUnit::MassG + ), + Err(RadrootsCoreUnitConvertError::NotMassUnit { + from: RadrootsCoreUnit::Each, + to: RadrootsCoreUnit::MassG + }) ); assert_eq!( - milliliters_factor_decimal(RadrootsCoreUnit::Each), - RadrootsCoreDecimal::ONE + convert_volume_decimal( + RadrootsCoreDecimal::ONE, + RadrootsCoreUnit::Each, + RadrootsCoreUnit::VolumeMl + ), + Err(RadrootsCoreUnitConvertError::NotVolumeUnit { + from: RadrootsCoreUnit::Each, + to: RadrootsCoreUnit::VolumeMl + }) ); } } diff --git a/crates/core/tests/currency.rs b/crates/core/tests/currency.rs @@ -90,4 +90,6 @@ fn serde_deserialize_paths_are_exercised() { 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")); + let non_string_err = serde_json::from_str::<RadrootsCoreCurrency>("123").unwrap_err(); + assert!(non_string_err.to_string().contains("invalid type")); } diff --git a/crates/core/tests/decimal.rs b/crates/core/tests/decimal.rs @@ -50,3 +50,12 @@ fn from_str_exact_and_conversion_impl_paths_are_exercised() { assert_eq!(from_u64, common::dec("11")); assert_eq!(from_i64, common::dec("-9")); } + +#[cfg(feature = "serde")] +#[test] +fn serde_deserialize_error_paths_are_exercised() { + let parse_err = serde_json::from_str::<RadrootsCoreDecimal>("\"not-a-decimal\"").unwrap_err(); + assert!(!parse_err.to_string().is_empty()); + let non_string_err = serde_json::from_str::<RadrootsCoreDecimal>("123").unwrap_err(); + assert!(non_string_err.to_string().contains("invalid type")); +} diff --git a/crates/core/tests/money.rs b/crates/core/tests/money.rs @@ -124,6 +124,18 @@ fn minor_units_u32_overflow_is_detected() { } #[test] +fn minor_units_u32_exact_success_path_is_exercised() { + let usd = RadrootsCoreCurrency::USD; + let m = RadrootsCoreMoney::new(common::dec("42.01"), usd); + assert_eq!(m.to_minor_units_u32_exact().unwrap(), 4201); + let fractional = RadrootsCoreMoney::new(common::dec("1.001"), usd); + assert_eq!( + fractional.to_minor_units_u32_exact(), + Err(RadrootsCoreMoneyInvariantError::NotWholeMinorUnits) + ); +} + +#[test] fn from_minor_units_u32_and_u32_rounded_paths_are_exercised() { let usd = RadrootsCoreCurrency::USD; let from_u32 = RadrootsCoreMoney::from_minor_units_u32(505, usd); @@ -137,6 +149,21 @@ fn from_minor_units_u32_and_u32_rounded_paths_are_exercised() { } #[test] +fn minor_units_u32_rounded_overflow_is_detected() { + let usd = RadrootsCoreCurrency::USD; + let too_large = RadrootsCoreMoney::from_minor_units_u64(u64::from(u32::MAX) + 1, usd); + assert_eq!( + too_large.to_minor_units_u32_rounded(RoundingStrategy::MidpointAwayFromZero), + Err(RadrootsCoreMoneyInvariantError::AmountOverflow) + ); + let negative = RadrootsCoreMoney::new(common::dec("-1.00"), usd); + assert_eq!( + negative.to_minor_units_u32_rounded(RoundingStrategy::MidpointAwayFromZero), + Err(RadrootsCoreMoneyInvariantError::AmountOverflow) + ); +} + +#[test] fn with_scale_path_is_exercised() { let usd = RadrootsCoreCurrency::USD; let m = RadrootsCoreMoney::new(common::dec("1.2300"), usd).with_scale(1); diff --git a/crates/core/tests/quantity.rs b/crates/core/tests/quantity.rs @@ -141,6 +141,46 @@ fn display_without_label_and_error_display_are_exercised() { } #[test] +fn display_propagates_formatter_errors() { + use core::fmt::{self, Write}; + + struct FailWriter { + fail_on_paren: bool, + fail_on_call: usize, + calls: usize, + } + + impl Write for FailWriter { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.calls += 1; + if self.fail_on_paren && s.contains('(') { + return Err(fmt::Error); + } + if self.calls == self.fail_on_call { + Err(fmt::Error) + } else { + Ok(()) + } + } + } + + let with_label = common::qty("1.5", RadrootsCoreUnit::Each).with_label("bag"); + let mut first_write_fails = FailWriter { + fail_on_paren: false, + fail_on_call: 1, + calls: 0, + }; + assert!(fmt::write(&mut first_write_fails, format_args!("{with_label}")).is_err()); + + let mut second_write_fails = FailWriter { + fail_on_paren: true, + fail_on_call: usize::MAX, + calls: 0, + }; + assert!(fmt::write(&mut second_write_fails, format_args!("{with_label}")).is_err()); +} + +#[test] fn try_convert_to_changes_unit_and_amount() { let q = common::qty("1", RadrootsCoreUnit::MassKg); let converted = q.try_convert_to(RadrootsCoreUnit::MassG).unwrap(); diff --git a/crates/core/tests/quantity_price.rs b/crates/core/tests/quantity_price.rs @@ -64,6 +64,21 @@ fn try_cost_for_validates_quantity_and_units() { } #[test] +fn try_cost_for_rounded_error_path_is_exercised() { + let price = RadrootsCoreQuantityPrice::new( + common::money("10", "USD"), + common::qty("1", RadrootsCoreUnit::Each), + ); + assert_eq!( + price.try_cost_for_rounded(&common::qty("1", RadrootsCoreUnit::MassKg)), + Err(RadrootsCoreQuantityPriceError::UnitMismatch { + have: RadrootsCoreUnit::MassKg, + want: RadrootsCoreUnit::Each + }) + ); +} + +#[test] fn try_cost_for_amount_in_converts_mass_units() { let price = RadrootsCoreQuantityPrice::new( common::money("10", "USD"), diff --git a/crates/core/tests/serde.rs b/crates/core/tests/serde.rs @@ -40,6 +40,13 @@ fn quantity_deserializes_decimal_str_via_serde_ext() { } #[test] +fn quantity_rejects_non_string_decimal_amount() { + let raw = r#"{"amount":1.23,"unit":"kg"}"#; + let err = serde_json::from_str::<RadrootsCoreQuantity>(raw).unwrap_err(); + assert!(err.to_string().contains("invalid type")); +} + +#[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 @@ -86,6 +86,10 @@ fn parse_mass_unit_enforces_mass_only() { parse_mass_unit("each"), Err(RadrootsCoreUnitParseError::NotAMassUnit) ); + assert_eq!( + parse_mass_unit("bogus"), + Err(RadrootsCoreUnitParseError::UnknownUnit) + ); } #[test] @@ -95,6 +99,10 @@ fn parse_volume_unit_enforces_volume_only() { parse_volume_unit("kg"), Err(RadrootsCoreUnitParseError::NotAVolumeUnit) ); + assert_eq!( + parse_volume_unit("bogus"), + Err(RadrootsCoreUnitParseError::UnknownUnit) + ); } #[test] @@ -104,11 +112,15 @@ fn convert_mass_decimal_converts_between_mass_units() { 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(); + let g_to_oz = convert_mass_decimal(common::dec("28.349523125"), MassG, MassOz).unwrap(); + let g_to_lb = convert_mass_decimal(common::dec("453.59237"), MassG, MassLb).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")); + assert_eq!(g_to_oz, common::dec("1")); + assert_eq!(g_to_lb, common::dec("1")); } #[test] @@ -254,8 +266,19 @@ fn convert_unit_decimal_rejects_mismatched_dimensions() { #[cfg(feature = "serde")] #[test] fn serde_roundtrip_for_unit_paths() { - let json = serde_json::to_string(&RadrootsCoreUnit::VolumeL).unwrap(); - assert_eq!(json, "\"l\""); + use RadrootsCoreUnit::*; + let all = [Each, MassKg, MassG, MassOz, MassLb, VolumeL, VolumeMl]; + for unit in all { + let json = serde_json::to_string(&unit).unwrap(); + let back: RadrootsCoreUnit = serde_json::from_str(&json).unwrap(); + assert_eq!(back, unit); + } let back: RadrootsCoreUnit = serde_json::from_str("\"kg\"").unwrap(); assert_eq!(back, RadrootsCoreUnit::MassKg); + let unknown_err = serde_json::from_str::<RadrootsCoreUnit>("\"bogus\"").unwrap_err(); + assert!(unknown_err.to_string().contains("unknown unit")); + let err = serde_json::from_str::<RadrootsCoreUnit>("123").unwrap_err(); + assert!(err.to_string().contains("invalid type")); + let missing_err = serde_json::from_str::<RadrootsCoreUnit>("{}").unwrap_err(); + assert!(!missing_err.to_string().is_empty()); }