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:
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());
}