commit 8296746c59297158a32282fd06c25de72fc02ad8
parent 3bdc3c4683bf6807c8febcc9077ce4ced2aa6ed8
Author: triesap <tyson@radroots.org>
Date: Fri, 2 Jan 2026 16:47:33 +0000
core: refactor discount model and generalize unit conversions
- Replace discount enum variants with scoped struct plus threshold/value enums
- Add unit dimensions, canonical units, and volume parsing/conversion helpers
- Extend quantity and quantity_price with unit conversion and canonicalization APIs
- Expose rounding strategy for money quantization and update tests accordingly
Diffstat:
11 files changed, 413 insertions(+), 103 deletions(-)
diff --git a/core/src/discount.rs b/core/src/discount.rs
@@ -6,56 +6,50 @@ use alloc::string::String;
#[cfg_attr(feature = "typeshare", typeshare::typeshare)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+pub enum RadrootsCoreDiscountScope {
+ Bin,
+ OrderTotal,
+}
+
+#[cfg_attr(feature = "typeshare", typeshare::typeshare)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))]
+pub enum RadrootsCoreDiscountThreshold {
+ BinCount { bin_id: String, min: u32 },
+ OrderQuantity { min: crate::RadrootsCoreQuantity },
+}
+
+#[cfg_attr(feature = "typeshare", typeshare::typeshare)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))]
pub enum RadrootsCoreDiscountValue {
- Money(crate::RadrootsCoreMoney),
+ MoneyPerBin(crate::RadrootsCoreMoney),
Percent(crate::RadrootsCorePercent),
}
#[cfg_attr(feature = "typeshare", typeshare::typeshare)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
-#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "kind", content = "amount"))]
-pub enum RadrootsCoreDiscount {
- QuantityThreshold {
- ref_key: Option<String>,
- threshold: crate::RadrootsCoreQuantity,
- value: crate::RadrootsCoreMoney,
- },
- MassThreshold {
- threshold: crate::RadrootsCoreQuantity,
- value: crate::RadrootsCoreMoney,
- },
- SubtotalThreshold {
- threshold: crate::RadrootsCoreMoney,
- value: RadrootsCoreDiscountValue,
- },
- TotalThreshold {
- total_min: crate::RadrootsCoreMoney,
- value: crate::RadrootsCorePercent,
- },
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+pub struct RadrootsCoreDiscount {
+ pub scope: RadrootsCoreDiscountScope,
+ pub threshold: RadrootsCoreDiscountThreshold,
+ pub value: RadrootsCoreDiscountValue,
}
impl RadrootsCoreDiscount {
pub fn is_non_negative(&self) -> bool {
- match self {
- RadrootsCoreDiscount::QuantityThreshold {
- threshold, value, ..
- } => !threshold.amount.is_sign_negative() && !value.amount.is_sign_negative(),
- RadrootsCoreDiscount::MassThreshold { threshold, value } => {
- !threshold.amount.is_sign_negative() && !value.amount.is_sign_negative()
- }
- RadrootsCoreDiscount::SubtotalThreshold { threshold, value } => {
- let money_ok = !threshold.amount.is_sign_negative();
- let val_ok = match value {
- RadrootsCoreDiscountValue::Money(m) => !m.amount.is_sign_negative(),
- RadrootsCoreDiscountValue::Percent(p) => !p.value.is_sign_negative(),
- };
- money_ok && val_ok
- }
- RadrootsCoreDiscount::TotalThreshold { total_min, value } => {
- !total_min.amount.is_sign_negative() && !value.value.is_sign_negative()
- }
- }
+ let threshold_ok = match &self.threshold {
+ RadrootsCoreDiscountThreshold::BinCount { .. } => true,
+ RadrootsCoreDiscountThreshold::OrderQuantity { min } => !min.amount.is_sign_negative(),
+ };
+ let value_ok = match &self.value {
+ RadrootsCoreDiscountValue::MoneyPerBin(m) => !m.amount.is_sign_negative(),
+ RadrootsCoreDiscountValue::Percent(p) => !p.value.is_sign_negative(),
+ };
+ threshold_ok && value_ok
}
}
diff --git a/core/src/lib.rs b/core/src/lib.rs
@@ -16,7 +16,10 @@ pub mod unit;
pub use currency::{RadrootsCoreCurrency, RadrootsCoreCurrencyParseError};
pub use decimal::RadrootsCoreDecimal;
-pub use discount::{RadrootsCoreDiscount, RadrootsCoreDiscountValue};
+pub use discount::{
+ RadrootsCoreDiscount, RadrootsCoreDiscountScope, RadrootsCoreDiscountThreshold,
+ RadrootsCoreDiscountValue,
+};
pub use money::{RadrootsCoreMoney, RadrootsCoreMoneyInvariantError};
pub use percent::{RadrootsCorePercent, RadrootsCorePercentParseError};
pub use quantity::{RadrootsCoreQuantity, RadrootsCoreQuantityInvariantError};
@@ -24,6 +27,7 @@ pub use quantity_price::{
RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreQuantityPriceOps,
};
pub use unit::{
- convert_mass_decimal, parse_mass_unit, RadrootsCoreUnit, RadrootsCoreUnitConvertError,
- RadrootsCoreUnitParseError,
+ convert_mass_decimal, convert_unit_decimal, convert_volume_decimal, parse_mass_unit,
+ parse_volume_unit, RadrootsCoreUnit, RadrootsCoreUnitConvertError,
+ RadrootsCoreUnitDimension, RadrootsCoreUnitParseError,
};
diff --git a/core/src/money.rs b/core/src/money.rs
@@ -62,12 +62,14 @@ impl RadrootsCoreMoney {
}
#[inline]
- pub fn quantize_to_currency(mut self) -> Self {
+ pub fn quantize_to_currency(self) -> Self {
+ self.quantize_to_currency_with_strategy(RoundingStrategy::MidpointAwayFromZero)
+ }
+
+ #[inline]
+ pub fn quantize_to_currency_with_strategy(mut self, strategy: RoundingStrategy) -> Self {
let e = self.currency.minor_unit_exponent();
- self.amount.0 = self
- .amount
- .0
- .round_dp_with_strategy(e, RoundingStrategy::MidpointAwayFromZero);
+ self.amount.0 = self.amount.0.round_dp_with_strategy(e, strategy);
self
}
diff --git a/core/src/quantity.rs b/core/src/quantity.rs
@@ -1,7 +1,7 @@
use core::fmt;
use crate::RadrootsCoreDecimal;
-use crate::unit::RadrootsCoreUnit;
+use crate::unit::{convert_unit_decimal, RadrootsCoreUnit, RadrootsCoreUnitConvertError};
#[cfg(feature = "std")]
use std::string::String;
@@ -62,6 +62,37 @@ impl RadrootsCoreQuantity {
}
#[inline]
+ pub fn is_canonical(&self) -> bool {
+ self.unit == self.unit.canonical_unit()
+ }
+
+ #[inline]
+ pub fn canonical_unit(&self) -> RadrootsCoreUnit {
+ self.unit.canonical_unit()
+ }
+
+ #[inline]
+ pub fn try_convert_to(
+ &self,
+ unit: RadrootsCoreUnit,
+ ) -> Result<RadrootsCoreQuantity, RadrootsCoreUnitConvertError> {
+ if self.unit == unit {
+ return Ok(self.clone());
+ }
+ let amount = convert_unit_decimal(self.amount, self.unit, unit)?;
+ Ok(RadrootsCoreQuantity {
+ amount,
+ unit,
+ label: self.label.clone(),
+ })
+ }
+
+ #[inline]
+ pub fn to_canonical(&self) -> Result<RadrootsCoreQuantity, RadrootsCoreUnitConvertError> {
+ self.try_convert_to(self.unit.canonical_unit())
+ }
+
+ #[inline]
pub fn ensure_non_negative(&self) -> Result<(), RadrootsCoreQuantityInvariantError> {
if self.amount.is_sign_negative() {
return Err(RadrootsCoreQuantityInvariantError::NegativeAmount);
diff --git a/core/src/quantity_price.rs b/core/src/quantity_price.rs
@@ -56,14 +56,14 @@ impl RadrootsCoreQuantityPrice {
amount: RadrootsCoreDecimal,
unit: RadrootsCoreUnit,
) -> Result<RadrootsCoreMoney, RadrootsCoreQuantityPriceError> {
- use crate::unit::convert_mass_decimal;
+ use crate::unit::convert_unit_decimal;
let target = self.quantity.unit;
let normalized = if unit == target {
amount
} else {
- convert_mass_decimal(amount, unit, target).map_err(|_| {
+ convert_unit_decimal(amount, unit, target).map_err(|_| {
RadrootsCoreQuantityPriceError::NonConvertibleUnits {
from: unit,
to: target,
@@ -74,6 +74,60 @@ impl RadrootsCoreQuantityPrice {
let qty = RadrootsCoreQuantity::new(normalized, target);
self.try_cost_for_rounded(&qty)
}
+
+ #[inline]
+ pub fn try_cost_for_quantity_in(
+ &self,
+ qty: &RadrootsCoreQuantity,
+ ) -> Result<RadrootsCoreMoney, RadrootsCoreQuantityPriceError> {
+ self.try_cost_for_amount_in(qty.amount, qty.unit)
+ }
+
+ #[inline]
+ pub fn is_price_per_canonical_unit(&self) -> bool {
+ self.quantity.unit == self.quantity.unit.canonical_unit()
+ && self.quantity.amount == RadrootsCoreDecimal::ONE
+ }
+
+ #[inline]
+ pub fn try_to_unit_price(
+ &self,
+ unit: RadrootsCoreUnit,
+ ) -> Result<RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError> {
+ use crate::unit::convert_unit_decimal;
+
+ if self.quantity.amount.is_zero() {
+ return Err(RadrootsCoreQuantityPriceError::PerQuantityZero);
+ }
+
+ let normalized = if self.quantity.unit == unit {
+ self.quantity.amount
+ } else {
+ convert_unit_decimal(self.quantity.amount, self.quantity.unit, unit).map_err(|_| {
+ RadrootsCoreQuantityPriceError::NonConvertibleUnits {
+ from: self.quantity.unit,
+ to: unit,
+ }
+ })?
+ };
+
+ if normalized.is_zero() {
+ return Err(RadrootsCoreQuantityPriceError::PerQuantityZero);
+ }
+
+ let amount = self.amount.div_decimal(normalized);
+ Ok(RadrootsCoreQuantityPrice {
+ amount,
+ quantity: RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, unit),
+ })
+ }
+
+ #[inline]
+ pub fn try_to_canonical_unit_price(
+ &self,
+ ) -> Result<RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError> {
+ self.try_to_unit_price(self.quantity.unit.canonical_unit())
+ }
}
impl RadrootsCoreQuantityPriceOps for RadrootsCoreQuantityPrice {
diff --git a/core/src/unit.rs b/core/src/unit.rs
@@ -15,6 +15,14 @@ use crate::RadrootsCoreDecimal;
#[cfg_attr(feature = "typeshare", typeshare::typeshare)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum RadrootsCoreUnitDimension {
+ Count,
+ Mass,
+ Volume,
+}
+
+#[cfg_attr(feature = "typeshare", typeshare::typeshare)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum RadrootsCoreUnit {
Each,
MassKg,
@@ -40,31 +48,32 @@ impl RadrootsCoreUnit {
}
pub fn same_dimension(a: Self, b: Self) -> bool {
- use RadrootsCoreUnit::*;
- matches!(
- (a, b),
- (Each, Each)
- | (MassKg, MassKg)
- | (MassKg, MassG)
- | (MassKg, MassOz)
- | (MassKg, MassLb)
- | (MassG, MassKg)
- | (MassG, MassG)
- | (MassG, MassOz)
- | (MassG, MassLb)
- | (MassOz, MassKg)
- | (MassOz, MassG)
- | (MassOz, MassOz)
- | (MassOz, MassLb)
- | (MassLb, MassKg)
- | (MassLb, MassG)
- | (MassLb, MassOz)
- | (MassLb, MassLb)
- | (VolumeL, VolumeL)
- | (VolumeL, VolumeMl)
- | (VolumeMl, VolumeL)
- | (VolumeMl, VolumeMl)
- )
+ a.dimension() == b.dimension()
+ }
+
+ #[inline]
+ pub fn dimension(&self) -> RadrootsCoreUnitDimension {
+ match self {
+ Self::Each => RadrootsCoreUnitDimension::Count,
+ Self::MassKg | Self::MassG | Self::MassOz | Self::MassLb => {
+ RadrootsCoreUnitDimension::Mass
+ }
+ Self::VolumeL | Self::VolumeMl => RadrootsCoreUnitDimension::Volume,
+ }
+ }
+
+ #[inline]
+ pub fn canonical_unit(&self) -> Self {
+ match self.dimension() {
+ RadrootsCoreUnitDimension::Count => Self::Each,
+ RadrootsCoreUnitDimension::Mass => Self::MassG,
+ RadrootsCoreUnitDimension::Volume => Self::VolumeMl,
+ }
+ }
+
+ #[inline]
+ pub fn is_volume(&self) -> bool {
+ matches!(self, Self::VolumeL | Self::VolumeMl)
}
#[inline]
@@ -74,6 +83,11 @@ impl RadrootsCoreUnit {
Self::MassKg | Self::MassG | Self::MassOz | Self::MassLb
)
}
+
+ #[inline]
+ pub fn is_count(&self) -> bool {
+ matches!(self, Self::Each)
+ }
}
impl fmt::Display for RadrootsCoreUnit {
@@ -86,6 +100,7 @@ impl fmt::Display for RadrootsCoreUnit {
pub enum RadrootsCoreUnitParseError {
UnknownUnit,
NotAMassUnit,
+ NotAVolumeUnit,
}
impl fmt::Display for RadrootsCoreUnitParseError {
@@ -93,6 +108,7 @@ impl fmt::Display for RadrootsCoreUnitParseError {
match self {
Self::UnknownUnit => write!(f, "unknown unit string"),
Self::NotAMassUnit => write!(f, "unit is not a mass unit"),
+ Self::NotAVolumeUnit => write!(f, "unit is not a volume unit"),
}
}
}
@@ -106,6 +122,14 @@ pub enum RadrootsCoreUnitConvertError {
from: RadrootsCoreUnit,
to: RadrootsCoreUnit,
},
+ NotVolumeUnit {
+ from: RadrootsCoreUnit,
+ to: RadrootsCoreUnit,
+ },
+ NotConvertibleUnits {
+ from: RadrootsCoreUnit,
+ to: RadrootsCoreUnit,
+ },
}
impl fmt::Display for RadrootsCoreUnitConvertError {
@@ -114,6 +138,12 @@ impl fmt::Display for RadrootsCoreUnitConvertError {
RadrootsCoreUnitConvertError::NotMassUnit { from, to } => {
write!(f, "unit conversion requires mass units: {from} -> {to}")
}
+ RadrootsCoreUnitConvertError::NotVolumeUnit { from, to } => {
+ write!(f, "unit conversion requires volume units: {from} -> {to}")
+ }
+ RadrootsCoreUnitConvertError::NotConvertibleUnits { from, to } => {
+ write!(f, "unit conversion requires matching dimensions: {from} -> {to}")
+ }
}
}
}
@@ -167,6 +197,16 @@ pub fn parse_mass_unit(s: &str) -> Result<RadrootsCoreUnit, RadrootsCoreUnitPars
}
#[inline]
+pub fn parse_volume_unit(s: &str) -> Result<RadrootsCoreUnit, RadrootsCoreUnitParseError> {
+ let u: RadrootsCoreUnit = RadrootsCoreUnit::from_str(s)?;
+ if u.is_volume() {
+ Ok(u)
+ } else {
+ Err(RadrootsCoreUnitParseError::NotAVolumeUnit)
+ }
+}
+
+#[inline]
fn grams_factor_decimal(u: RadrootsCoreUnit) -> RadrootsCoreDecimal {
match u {
RadrootsCoreUnit::MassG => RadrootsCoreDecimal::ONE,
@@ -178,6 +218,15 @@ fn grams_factor_decimal(u: RadrootsCoreUnit) -> RadrootsCoreDecimal {
}
#[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,
@@ -189,3 +238,37 @@ pub fn convert_mass_decimal(
let amount_g = amount * grams_factor_decimal(from);
Ok(amount_g / grams_factor_decimal(to))
}
+
+#[inline]
+pub fn convert_volume_decimal(
+ amount: RadrootsCoreDecimal,
+ 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))
+}
+
+#[inline]
+pub fn convert_unit_decimal(
+ amount: RadrootsCoreDecimal,
+ 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);
+ }
+ if from.is_volume() {
+ return convert_volume_decimal(amount, from, to);
+ }
+ Err(RadrootsCoreUnitConvertError::NotConvertibleUnits { from, to })
+}
diff --git a/core/tests/discount.rs b/core/tests/discount.rs
@@ -1,51 +1,57 @@
mod common;
-use radroots_core::{RadrootsCoreDiscount, RadrootsCoreDiscountValue, RadrootsCorePercent};
+use radroots_core::{
+ RadrootsCoreDiscount, RadrootsCoreDiscountScope, RadrootsCoreDiscountThreshold,
+ RadrootsCoreDiscountValue, RadrootsCorePercent, RadrootsCoreUnit,
+};
#[test]
-fn is_non_negative_validates_all_discount_shapes() {
+fn is_non_negative_validates_discount_shapes() {
let money_pos = common::money("1", "USD");
let money_neg = common::money("-1", "USD");
- let qty_pos = common::qty("1", radroots_core::RadrootsCoreUnit::Each);
- let qty_neg = common::qty("-1", radroots_core::RadrootsCoreUnit::Each);
+ let qty_pos = common::qty("1", RadrootsCoreUnit::Each);
+ let qty_neg = common::qty("-1", RadrootsCoreUnit::Each);
let pct_pos = RadrootsCorePercent::new(common::dec("10"));
let pct_neg = RadrootsCorePercent::new(common::dec("-5"));
- let d = RadrootsCoreDiscount::QuantityThreshold {
- ref_key: None,
- threshold: qty_pos.clone(),
- value: money_pos.clone(),
+ let d = RadrootsCoreDiscount {
+ scope: RadrootsCoreDiscountScope::Bin,
+ threshold: RadrootsCoreDiscountThreshold::BinCount {
+ bin_id: "bin-1".to_string(),
+ min: 2,
+ },
+ value: RadrootsCoreDiscountValue::MoneyPerBin(money_pos.clone()),
};
assert!(d.is_non_negative());
- let d = RadrootsCoreDiscount::QuantityThreshold {
- ref_key: None,
- threshold: qty_neg,
- value: money_pos.clone(),
+ let d = RadrootsCoreDiscount {
+ scope: RadrootsCoreDiscountScope::Bin,
+ threshold: RadrootsCoreDiscountThreshold::BinCount {
+ bin_id: "bin-1".to_string(),
+ min: 2,
+ },
+ value: RadrootsCoreDiscountValue::MoneyPerBin(money_neg),
};
assert!(!d.is_non_negative());
- let d = RadrootsCoreDiscount::MassThreshold {
- threshold: qty_pos.clone(),
- value: money_neg.clone(),
- };
- assert!(!d.is_non_negative());
-
- let d = RadrootsCoreDiscount::SubtotalThreshold {
- threshold: money_pos.clone(),
+ let d = RadrootsCoreDiscount {
+ scope: RadrootsCoreDiscountScope::OrderTotal,
+ threshold: RadrootsCoreDiscountThreshold::OrderQuantity { min: qty_pos.clone() },
value: RadrootsCoreDiscountValue::Percent(pct_pos.clone()),
};
assert!(d.is_non_negative());
- let d = RadrootsCoreDiscount::SubtotalThreshold {
- threshold: money_pos.clone(),
- value: RadrootsCoreDiscountValue::Percent(pct_neg),
+ let d = RadrootsCoreDiscount {
+ scope: RadrootsCoreDiscountScope::OrderTotal,
+ threshold: RadrootsCoreDiscountThreshold::OrderQuantity { min: qty_neg },
+ value: RadrootsCoreDiscountValue::Percent(pct_pos),
};
assert!(!d.is_non_negative());
- let d = RadrootsCoreDiscount::TotalThreshold {
- total_min: money_pos,
- value: pct_pos,
+ let d = RadrootsCoreDiscount {
+ scope: RadrootsCoreDiscountScope::OrderTotal,
+ threshold: RadrootsCoreDiscountThreshold::OrderQuantity { min: qty_pos },
+ value: RadrootsCoreDiscountValue::Percent(pct_neg),
};
- assert!(d.is_non_negative());
+ assert!(!d.is_non_negative());
}
diff --git a/core/tests/money.rs b/core/tests/money.rs
@@ -33,6 +33,17 @@ fn quantize_to_currency_rounds_midpoint_away_from_zero() {
}
#[test]
+fn quantize_to_currency_with_strategy_uses_strategy() {
+ let usd = RadrootsCoreCurrency::USD;
+ let a = RadrootsCoreMoney::new(common::dec("1.235"), usd)
+ .quantize_to_currency_with_strategy(RoundingStrategy::MidpointTowardZero);
+ let b = RadrootsCoreMoney::new(common::dec("-1.235"), usd)
+ .quantize_to_currency_with_strategy(RoundingStrategy::MidpointTowardZero);
+ assert_eq!(a.amount, common::dec("1.23"));
+ assert_eq!(b.amount, common::dec("-1.23"));
+}
+
+#[test]
fn checked_add_and_sub_require_currency_match() {
let usd = RadrootsCoreCurrency::USD;
let eur = RadrootsCoreCurrency::EUR;
diff --git a/core/tests/quantity.rs b/core/tests/quantity.rs
@@ -73,3 +73,32 @@ fn display_includes_label_when_present() {
let q = common::qty("1.5", RadrootsCoreUnit::Each).with_label("bag");
assert_eq!(q.to_string(), "1.5 each (bag)");
}
+
+#[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();
+ assert_eq!(converted.amount, common::dec("1000"));
+ assert_eq!(converted.unit, RadrootsCoreUnit::MassG);
+}
+
+#[test]
+fn to_canonical_converts_mass_and_volume() {
+ let q = common::qty("2", RadrootsCoreUnit::VolumeL);
+ let canonical = q.to_canonical().unwrap();
+ assert_eq!(canonical.unit, RadrootsCoreUnit::VolumeMl);
+ assert_eq!(canonical.amount, common::dec("2000"));
+}
+
+#[test]
+fn try_convert_to_rejects_mismatched_dimensions() {
+ let q = common::qty("1", RadrootsCoreUnit::Each);
+ let err = q.try_convert_to(RadrootsCoreUnit::MassG).unwrap_err();
+ assert_eq!(
+ err,
+ radroots_core::RadrootsCoreUnitConvertError::NotConvertibleUnits {
+ from: RadrootsCoreUnit::Each,
+ to: RadrootsCoreUnit::MassG
+ }
+ );
+}
diff --git a/core/tests/quantity_price.rs b/core/tests/quantity_price.rs
@@ -76,6 +76,18 @@ fn try_cost_for_amount_in_converts_mass_units() {
}
#[test]
+fn try_cost_for_amount_in_converts_volume_units() {
+ let price = RadrootsCoreQuantityPrice::new(
+ common::money("10", "USD"),
+ common::qty("1", RadrootsCoreUnit::VolumeL),
+ );
+ let cost = price
+ .try_cost_for_amount_in(common::dec("500"), RadrootsCoreUnit::VolumeMl)
+ .unwrap();
+ assert_eq!(cost.amount, common::dec("5"));
+}
+
+#[test]
fn try_cost_for_amount_in_rejects_non_convertible_units() {
let price = RadrootsCoreQuantityPrice::new(
common::money("10", "USD"),
@@ -89,3 +101,31 @@ fn try_cost_for_amount_in_rejects_non_convertible_units() {
})
);
}
+
+#[test]
+fn try_to_canonical_unit_price_converts_units() {
+ let price = RadrootsCoreQuantityPrice::new(
+ common::money("6.99", "USD"),
+ common::qty("1", RadrootsCoreUnit::MassLb),
+ );
+ let canonical = price.try_to_canonical_unit_price().unwrap();
+ assert_eq!(canonical.quantity.unit, RadrootsCoreUnit::MassG);
+ assert_eq!(canonical.quantity.amount, common::dec("1"));
+ let expected = common::dec("6.99") / common::dec("453.59237");
+ assert_eq!(canonical.amount.amount, expected);
+}
+
+#[test]
+fn is_price_per_canonical_unit_detects_canonical() {
+ let price = RadrootsCoreQuantityPrice::new(
+ common::money("1.00", "USD"),
+ common::qty("1", RadrootsCoreUnit::MassG),
+ );
+ assert!(price.is_price_per_canonical_unit());
+
+ let price = RadrootsCoreQuantityPrice::new(
+ common::money("1.00", "USD"),
+ common::qty("1", RadrootsCoreUnit::MassKg),
+ );
+ assert!(!price.is_price_per_canonical_unit());
+}
diff --git a/core/tests/unit.rs b/core/tests/unit.rs
@@ -4,7 +4,8 @@ use core::str::FromStr;
use radroots_core::{
RadrootsCoreUnit, RadrootsCoreUnitConvertError, RadrootsCoreUnitParseError,
- convert_mass_decimal, parse_mass_unit,
+ convert_mass_decimal, convert_unit_decimal, convert_volume_decimal, parse_mass_unit,
+ parse_volume_unit,
};
#[test]
@@ -51,6 +52,15 @@ fn same_dimension_matches_mass_and_volume_groups() {
}
#[test]
+fn canonical_unit_maps_dimensions() {
+ use RadrootsCoreUnit::*;
+ assert_eq!(Each.canonical_unit(), Each);
+ assert_eq!(MassKg.canonical_unit(), MassG);
+ assert_eq!(MassLb.canonical_unit(), MassG);
+ assert_eq!(VolumeL.canonical_unit(), VolumeMl);
+}
+
+#[test]
fn parse_mass_unit_enforces_mass_only() {
assert_eq!(parse_mass_unit("kg"), Ok(RadrootsCoreUnit::MassKg));
assert_eq!(
@@ -60,6 +70,15 @@ fn parse_mass_unit_enforces_mass_only() {
}
#[test]
+fn parse_volume_unit_enforces_volume_only() {
+ assert_eq!(parse_volume_unit("l"), Ok(RadrootsCoreUnit::VolumeL));
+ assert_eq!(
+ parse_volume_unit("kg"),
+ Err(RadrootsCoreUnitParseError::NotAVolumeUnit)
+ );
+}
+
+#[test]
fn convert_mass_decimal_converts_between_mass_units() {
use RadrootsCoreUnit::*;
let kg_to_g = convert_mass_decimal(common::dec("1"), MassKg, MassG).unwrap();
@@ -87,3 +106,40 @@ fn convert_mass_decimal_rejects_non_mass_units() {
}
);
}
+
+#[test]
+fn convert_volume_decimal_converts_between_volume_units() {
+ use RadrootsCoreUnit::*;
+ let l_to_ml = convert_volume_decimal(common::dec("1"), VolumeL, VolumeMl).unwrap();
+ let ml_to_l = convert_volume_decimal(common::dec("250"), VolumeMl, VolumeL).unwrap();
+ assert_eq!(l_to_ml, common::dec("1000"));
+ assert_eq!(ml_to_l, common::dec("0.25"));
+}
+
+#[test]
+fn convert_unit_decimal_converts_matching_dimensions() {
+ use RadrootsCoreUnit::*;
+ let kg_to_g = convert_unit_decimal(common::dec("1"), MassKg, MassG).unwrap();
+ let l_to_ml = convert_unit_decimal(common::dec("2"), VolumeL, VolumeMl).unwrap();
+ let each_to_each = convert_unit_decimal(common::dec("3"), Each, Each).unwrap();
+ assert_eq!(kg_to_g, common::dec("1000"));
+ assert_eq!(l_to_ml, common::dec("2000"));
+ assert_eq!(each_to_each, common::dec("3"));
+}
+
+#[test]
+fn convert_unit_decimal_rejects_mismatched_dimensions() {
+ let err = convert_unit_decimal(
+ common::dec("1"),
+ RadrootsCoreUnit::Each,
+ RadrootsCoreUnit::MassG,
+ )
+ .unwrap_err();
+ assert_eq!(
+ err,
+ RadrootsCoreUnitConvertError::NotConvertibleUnits {
+ from: RadrootsCoreUnit::Each,
+ to: RadrootsCoreUnit::MassG
+ }
+ );
+}