lib

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

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:
Mcore/src/discount.rs | 72+++++++++++++++++++++++++++++++++---------------------------------------
Mcore/src/lib.rs | 10+++++++---
Mcore/src/money.rs | 12+++++++-----
Mcore/src/quantity.rs | 33++++++++++++++++++++++++++++++++-
Mcore/src/quantity_price.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcore/src/unit.rs | 133++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcore/tests/discount.rs | 60+++++++++++++++++++++++++++++++++---------------------------
Mcore/tests/money.rs | 11+++++++++++
Mcore/tests/quantity.rs | 29+++++++++++++++++++++++++++++
Mcore/tests/quantity_price.rs | 40++++++++++++++++++++++++++++++++++++++++
Mcore/tests/unit.rs | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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 + } + ); +}