From 381be41a126e80690424adf0189cb26276b75715 Mon Sep 17 00:00:00 2001 From: BDaws04 Date: Tue, 3 Feb 2026 00:11:24 +0000 Subject: [PATCH 1/9] add rescale ops to support checked + unchecked --- src/error.rs | 3 +++ src/lib.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/error.rs b/src/error.rs index 75be67f..d7e4ef5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,4 +6,7 @@ pub enum Error { InvalidCharacterInput(char), #[error("overflow: {0}")] Overflow(String), + #[error("precision loss: {0}")] + PrecisionLoss(String), } + diff --git a/src/lib.rs b/src/lib.rs index b880d92..fc15be2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,6 +96,14 @@ impl Display for DecimalU64 { } impl DecimalU64 { + #[inline] + pub const fn from_raw(unscaled: u64) -> Self { + Self { + unscaled, + phantom: PhantomData, + } + } + pub const ZERO: Self = DecimalU64::from_raw(0); pub const ONE: Self = DecimalU64::from_raw(S::SCALE_FACTOR); pub const TWO: Self = DecimalU64::from_raw(2 * S::SCALE_FACTOR); @@ -109,11 +117,43 @@ impl DecimalU64 { pub const TEN: Self = DecimalU64::from_raw(10 * S::SCALE_FACTOR); pub const MAX: Self = DecimalU64::from_raw(u64::MAX); - #[inline] - pub const fn from_raw(unscaled: u64) -> Self { - Self { - unscaled, - phantom: PhantomData, + pub fn rescale_unchecked(&self) -> DecimalU64 { + if T::SCALE >= S::SCALE { + let factor = 10u64.pow((T::SCALE - S::SCALE) as u32); + DecimalU64::::from_raw(self.unscaled.saturating_mul(factor)) + } else { + let factor = 10u64.pow((S::SCALE - T::SCALE) as u32); + DecimalU64::::from_raw(self.unscaled / factor) + } + } + + pub fn rescale(&self) -> Result, self::Error> { + if T::SCALE >= S::SCALE { + let factor = 10u64 + .checked_pow((T::SCALE - S::SCALE) as u32) + .ok_or_else(|| Error::Overflow(self.unscaled.to_string()))?; + let unscaled = self.unscaled + .checked_mul(factor) + .ok_or_else(|| Error::Overflow(self.unscaled.to_string()))?; + Ok(DecimalU64::::from_raw(unscaled)) + } else { + let factor = 10u64 + .checked_pow((S::SCALE - T::SCALE) as u32) + .ok_or_else(|| Error::Overflow(self.unscaled.to_string()))?; + + let truncated = self.unscaled / factor; + let remainder = self.unscaled % factor; + + if remainder != 0 { + Err(Error::PrecisionLoss(format!( + "Truncated {} fractional digits when rescaling {} -> {}", + S::SCALE - T::SCALE, + self.unscaled, + truncated + ))) + } else { + Ok(DecimalU64::::from_raw(truncated)) + } } } From 456f7ec78e473521d07848821c6fc66cbad688b4 Mon Sep 17 00:00:00 2001 From: BDaws04 Date: Wed, 4 Feb 2026 14:15:54 +0000 Subject: [PATCH 2/9] add rescale tests --- src/lib.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index fc15be2..8472591 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -481,4 +481,61 @@ mod tests { let mut buffer = [0u8; U8::REQUIRED_BUFFER_LEN]; DecimalU64::::MAX.write_to(&mut buffer); } + + fn decimal(unscaled: u64) -> DecimalU64 { + DecimalU64::::from_raw(unscaled) + } + + #[test] + fn test_rescale_unchecked_upscale() { + let d: DecimalU64 = decimal(123); // 1.23 + let rescaled: DecimalU64 = d.rescale_unchecked(); + // 123 * 10^(4-2) = 123 * 100 = 12300 + assert_eq!(rescaled.unscaled, 12300); + } + + #[test] + fn test_rescale_unchecked_downscale() { + let d: DecimalU64 = decimal(12345); // 1.2345 + let rescaled: DecimalU64 = d.rescale_unchecked(); + // 12345 / 10^(4-2) = 12345 / 100 = 123 + assert_eq!(rescaled.unscaled, 123); + } + + #[test] + fn test_rescale_upscale_ok() { + let d: DecimalU64 = decimal(50); + let rescaled: DecimalU64 = d.rescale().unwrap(); + assert_eq!(rescaled.unscaled, 5000); + } + + #[test] + fn test_rescale_downscale_exact() { + let d: DecimalU64 = decimal(1200); // 0.12 + let rescaled: DecimalU64 = d.rescale().unwrap(); + // 1200 / 100 = 12 + assert_eq!(rescaled.unscaled, 12); + } + + #[test] + fn test_rescale_downscale_precision_loss() { + let d: DecimalU64 = decimal(1234); // 0.1234 + let err = d.rescale::().unwrap_err(); + match err { + Error::PrecisionLoss(msg) => { + assert!(msg.contains("Truncated")); + assert!(msg.contains("1234")); + } + _ => panic!("Expected precision loss error"), + } + } + + #[test] + fn test_rescale_upscale_overflow() { + // This will overflow when multiplied by 10^(6-2) = 10_000 + let d: DecimalU64 = decimal(u64::MAX / 1000); + let res = d.rescale::(); + assert!(matches!(res, Err(Error::Overflow(_)))); + } + } From 944d666275c19d91173edbe8ca91000d5f6f1ad4 Mon Sep 17 00:00:00 2001 From: BDaws04 Date: Thu, 5 Feb 2026 14:25:33 +0000 Subject: [PATCH 3/9] add rescale benchmark --- benches/rescale.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 benches/rescale.rs diff --git a/benches/rescale.rs b/benches/rescale.rs new file mode 100644 index 0000000..6d0c069 --- /dev/null +++ b/benches/rescale.rs @@ -0,0 +1,48 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use decimal64::{DecimalU64, U2, U8}; +use std::str::FromStr; + +fn rescale_benchmark(c: &mut Criterion) { + let mut group = c.benchmark_group("decimal64_rescale"); + + let d_u2 = DecimalU64::::from_str("12345.67").unwrap(); + let d_u8 = DecimalU64::::from_str("12345.67000000").unwrap(); + + group.bench_function("rescale_unchecked_up", |b| { + b.iter(|| { + let r: DecimalU64 = + black_box(&d_u2).rescale_unchecked(); + black_box(r); + }) + }); + + group.bench_function("rescale_unchecked_down", |b| { + b.iter(|| { + let r: DecimalU64 = + black_box(&d_u8).rescale_unchecked(); + black_box(r); + }) + }); + + group.bench_function("rescale_checked_up", |b| { + b.iter(|| { + let r: DecimalU64 = + black_box(&d_u2).rescale().unwrap(); + black_box(r); + }) + }); + + + group.bench_function("rescale_checked_down", |b| { + b.iter(|| { + let r: DecimalU64 = + black_box(&d_u8).rescale().unwrap(); + black_box(r); + }) + }); + + group.finish(); +} + +criterion_group!(benches, rescale_benchmark); +criterion_main!(benches); From b3132d5aaf801f51364c8184beecf9e3721b00a0 Mon Sep 17 00:00:00 2001 From: BDaws04 Date: Thu, 5 Feb 2026 14:27:58 +0000 Subject: [PATCH 4/9] cargo fmt --- benches/rescale.rs | 15 +++++---------- src/error.rs | 1 - src/lib.rs | 4 ++-- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/benches/rescale.rs b/benches/rescale.rs index 6d0c069..6b25a32 100644 --- a/benches/rescale.rs +++ b/benches/rescale.rs @@ -1,4 +1,4 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use criterion::{Criterion, black_box, criterion_group, criterion_main}; use decimal64::{DecimalU64, U2, U8}; use std::str::FromStr; @@ -10,33 +10,28 @@ fn rescale_benchmark(c: &mut Criterion) { group.bench_function("rescale_unchecked_up", |b| { b.iter(|| { - let r: DecimalU64 = - black_box(&d_u2).rescale_unchecked(); + let r: DecimalU64 = black_box(&d_u2).rescale_unchecked(); black_box(r); }) }); group.bench_function("rescale_unchecked_down", |b| { b.iter(|| { - let r: DecimalU64 = - black_box(&d_u8).rescale_unchecked(); + let r: DecimalU64 = black_box(&d_u8).rescale_unchecked(); black_box(r); }) }); group.bench_function("rescale_checked_up", |b| { b.iter(|| { - let r: DecimalU64 = - black_box(&d_u2).rescale().unwrap(); + let r: DecimalU64 = black_box(&d_u2).rescale().unwrap(); black_box(r); }) }); - group.bench_function("rescale_checked_down", |b| { b.iter(|| { - let r: DecimalU64 = - black_box(&d_u8).rescale().unwrap(); + let r: DecimalU64 = black_box(&d_u8).rescale().unwrap(); black_box(r); }) }); diff --git a/src/error.rs b/src/error.rs index d7e4ef5..4002ead 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,4 +9,3 @@ pub enum Error { #[error("precision loss: {0}")] PrecisionLoss(String), } - diff --git a/src/lib.rs b/src/lib.rs index 8472591..9aab97f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -132,7 +132,8 @@ impl DecimalU64 { let factor = 10u64 .checked_pow((T::SCALE - S::SCALE) as u32) .ok_or_else(|| Error::Overflow(self.unscaled.to_string()))?; - let unscaled = self.unscaled + let unscaled = self + .unscaled .checked_mul(factor) .ok_or_else(|| Error::Overflow(self.unscaled.to_string()))?; Ok(DecimalU64::::from_raw(unscaled)) @@ -537,5 +538,4 @@ mod tests { let res = d.rescale::(); assert!(matches!(res, Err(Error::Overflow(_)))); } - } From bad739b4a59f691447c2c915f4f19d39aeed455e Mon Sep 17 00:00:00 2001 From: BDaws04 Date: Sat, 7 Feb 2026 02:47:31 +0000 Subject: [PATCH 5/9] make rescale_unchecked unsafe --- benches/rescale.rs | 4 ++-- src/lib.rs | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/benches/rescale.rs b/benches/rescale.rs index 6b25a32..7623c32 100644 --- a/benches/rescale.rs +++ b/benches/rescale.rs @@ -10,14 +10,14 @@ fn rescale_benchmark(c: &mut Criterion) { group.bench_function("rescale_unchecked_up", |b| { b.iter(|| { - let r: DecimalU64 = black_box(&d_u2).rescale_unchecked(); + let r: DecimalU64 = unsafe { black_box(&d_u2).rescale_unchecked() }; black_box(r); }) }); group.bench_function("rescale_unchecked_down", |b| { b.iter(|| { - let r: DecimalU64 = black_box(&d_u8).rescale_unchecked(); + let r: DecimalU64 = unsafe { black_box(&d_u8).rescale_unchecked() }; black_box(r); }) }); diff --git a/src/lib.rs b/src/lib.rs index 9aab97f..144e4cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -117,7 +117,15 @@ impl DecimalU64 { pub const TEN: Self = DecimalU64::from_raw(10 * S::SCALE_FACTOR); pub const MAX: Self = DecimalU64::from_raw(u64::MAX); - pub fn rescale_unchecked(&self) -> DecimalU64 { + /// Rescales this decimal to a different scale **without checking for overflow + /// or precision loss**. + /// + /// # Safety + /// The caller must ensure that: + /// - The multiplication by the rescaling factor does not overflow `u64` + /// - The resulting value is a valid `DecimalU64` + /// - Any precision loss caused by downscaling is acceptable + pub unsafe fn rescale_unchecked(&self) -> DecimalU64 { if T::SCALE >= S::SCALE { let factor = 10u64.pow((T::SCALE - S::SCALE) as u32); DecimalU64::::from_raw(self.unscaled.saturating_mul(factor)) @@ -490,7 +498,7 @@ mod tests { #[test] fn test_rescale_unchecked_upscale() { let d: DecimalU64 = decimal(123); // 1.23 - let rescaled: DecimalU64 = d.rescale_unchecked(); + let rescaled: DecimalU64 = unsafe { d.rescale_unchecked() }; // 123 * 10^(4-2) = 123 * 100 = 12300 assert_eq!(rescaled.unscaled, 12300); } @@ -498,7 +506,7 @@ mod tests { #[test] fn test_rescale_unchecked_downscale() { let d: DecimalU64 = decimal(12345); // 1.2345 - let rescaled: DecimalU64 = d.rescale_unchecked(); + let rescaled: DecimalU64 = unsafe { d.rescale_unchecked() }; // 12345 / 10^(4-2) = 12345 / 100 = 123 assert_eq!(rescaled.unscaled, 123); } From f06cea20090db92737c1474427bf400981b820b5 Mon Sep 17 00:00:00 2001 From: BDaws04 Date: Tue, 10 Feb 2026 13:45:28 +0000 Subject: [PATCH 6/9] change tests to use rstest + assert with string representations --- src/lib.rs | 79 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 144e4cc..9f23f6b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -258,6 +258,7 @@ impl DecimalU64 { #[cfg(test)] mod tests { use super::*; + use rstest::rstest; #[test] fn should_not_increase_size() { @@ -496,54 +497,86 @@ mod tests { } #[test] - fn test_rescale_unchecked_upscale() { - let d: DecimalU64 = decimal(123); // 1.23 + fn test_rescale_upscale_ok() { + let d: DecimalU64 = decimal(50); + let rescaled: DecimalU64 = d.rescale().unwrap(); + assert_eq!(rescaled.unscaled.to_string(), "5000"); + } + + #[rstest] + #[case(decimal::(123), 12300)] // 1.23 -> 1.2300 + fn test_rescale_unchecked_upscale(#[case] d: DecimalU64, #[case] expected: u64) { let rescaled: DecimalU64 = unsafe { d.rescale_unchecked() }; - // 123 * 10^(4-2) = 123 * 100 = 12300 - assert_eq!(rescaled.unscaled, 12300); + assert_eq!(rescaled.unscaled, expected); } - #[test] - fn test_rescale_unchecked_downscale() { - let d: DecimalU64 = decimal(12345); // 1.2345 + #[rstest] + #[case(decimal::(12345), 123)] // 1.2345 -> 1.23 + fn test_rescale_unchecked_downscale(#[case] d: DecimalU64, #[case] expected: u64) { let rescaled: DecimalU64 = unsafe { d.rescale_unchecked() }; - // 12345 / 10^(4-2) = 12345 / 100 = 123 - assert_eq!(rescaled.unscaled, 123); + assert_eq!(rescaled.unscaled, expected); } - #[test] - fn test_rescale_upscale_ok() { + #[rstest] + fn test_rescale_upscale() { let d: DecimalU64 = decimal(50); let rescaled: DecimalU64 = d.rescale().unwrap(); - assert_eq!(rescaled.unscaled, 5000); + assert_eq!(rescaled.unscaled.to_string(), "5000"); } - #[test] + #[rstest] fn test_rescale_downscale_exact() { - let d: DecimalU64 = decimal(1200); // 0.12 + let d: DecimalU64 = decimal(1200); let rescaled: DecimalU64 = d.rescale().unwrap(); - // 1200 / 100 = 12 - assert_eq!(rescaled.unscaled, 12); + assert_eq!(rescaled.unscaled.to_string(), "12"); } - #[test] - fn test_rescale_downscale_precision_loss() { - let d: DecimalU64 = decimal(1234); // 0.1234 + #[rstest] + fn test_rescale_zero() { + let d: DecimalU64 = decimal(0); + let rescaled: DecimalU64 = d.rescale().unwrap(); + assert_eq!(rescaled.unscaled.to_string(), "0"); + } + + #[rstest] + fn test_rescale_identity_scale() { + let d: DecimalU64 = decimal(123); + let rescaled: DecimalU64 = d.rescale().unwrap(); + assert_eq!(rescaled.unscaled.to_string(), "123"); + } + + #[rstest] + #[case(decimal::(1234))] // 0.1234 -> truncation + #[case(decimal::(100001))] // tiny truncation + fn test_rescale_downscale_precision_loss(#[case] d: DecimalU64) { let err = d.rescale::().unwrap_err(); match err { Error::PrecisionLoss(msg) => { assert!(msg.contains("Truncated")); - assert!(msg.contains("1234")); } - _ => panic!("Expected precision loss error"), + _ => panic!("Expected PrecisionLoss"), } } - #[test] + #[rstest] + fn test_rescale_downscale_to_zero() { + let d: DecimalU64 = decimal(1); // 0.000001 + let err = d.rescale::().unwrap_err(); + assert!(matches!(err, Error::PrecisionLoss(_))); + } + + #[rstest] fn test_rescale_upscale_overflow() { - // This will overflow when multiplied by 10^(6-2) = 10_000 + // overflow when multiplied by 10^(6-2) = 10_000 let d: DecimalU64 = decimal(u64::MAX / 1000); let res = d.rescale::(); assert!(matches!(res, Err(Error::Overflow(_)))); } + + #[rstest] + fn test_rescale_upscale_overflow_boundary() { + let d: DecimalU64 = decimal(u64::MAX / 100); + let res = d.rescale::(); + assert!(matches!(res, Err(Error::Overflow(_)))); + } } From 95c60c7b7f8dafd7f96749a3c5ea99da52ed5aa0 Mon Sep 17 00:00:00 2001 From: BDaws04 Date: Wed, 11 Feb 2026 13:22:13 +0000 Subject: [PATCH 7/9] update test suite for rescale --- src/lib.rs | 208 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 157 insertions(+), 51 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 9f23f6b..f8c89c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -496,87 +496,193 @@ mod tests { DecimalU64::::from_raw(unscaled) } - #[test] - fn test_rescale_upscale_ok() { - let d: DecimalU64 = decimal(50); - let rescaled: DecimalU64 = d.rescale().unwrap(); - assert_eq!(rescaled.unscaled.to_string(), "5000"); + // tests for rescale checked and unchecked + #[rstest] + #[case(decimal::(0), "0")] + #[case(decimal::(1), "10000000")] + #[case(decimal::(12), "12000000")] + #[case(decimal::(1234), "12340000")] + #[case(decimal::(999999), "99999900")] + fn should_upscale_checked(#[case] d: DecimalU64, #[case] expected: String) { + let rescaled: DecimalU64 = d.rescale().unwrap(); + assert_eq!(rescaled.unscaled.to_string(), expected); } #[rstest] - #[case(decimal::(123), 12300)] // 1.23 -> 1.2300 - fn test_rescale_unchecked_upscale(#[case] d: DecimalU64, #[case] expected: u64) { - let rescaled: DecimalU64 = unsafe { d.rescale_unchecked() }; - assert_eq!(rescaled.unscaled, expected); + #[case(decimal::(0), "0")] + #[case(decimal::(1200), "1200")] + #[case(decimal::(123400), "1234")] + #[case(decimal::(5000000), "500")] + #[case(decimal::(99000000), "99")] + fn should_downscale_checked(#[case] d: DecimalU64, #[case] expected: String) { + let rescaled: DecimalU64 = d.rescale().unwrap(); + assert_eq!(rescaled.unscaled.to_string(), expected); } #[rstest] - #[case(decimal::(12345), 123)] // 1.2345 -> 1.23 - fn test_rescale_unchecked_downscale(#[case] d: DecimalU64, #[case] expected: u64) { + #[case(decimal::(0), "0")] + #[case(decimal::(1), "10000000")] + #[case(decimal::(12), "12000000")] + #[case(decimal::(1234), "12340000")] + #[case(decimal::(999999), "99999900")] + fn should_upscale_unchecked(#[case] d: DecimalU64, #[case] expected: String) { + let rescaled: DecimalU64 = unsafe { d.rescale_unchecked() }; + assert_eq!(rescaled.unscaled.to_string(), expected); + } + + #[rstest] + #[case(decimal::(0), "0")] + #[case(decimal::(1200), "1200")] + #[case(decimal::(123400), "1234")] + #[case(decimal::(5000000), "500")] + #[case(decimal::(99999900), "99")] + fn should_downscale_unchecked(#[case] d: DecimalU64, #[case] expected: String) { let rescaled: DecimalU64 = unsafe { d.rescale_unchecked() }; - assert_eq!(rescaled.unscaled, expected); + assert_eq!(rescaled.unscaled.to_string(), expected); } #[rstest] - fn test_rescale_upscale() { - let d: DecimalU64 = decimal(50); - let rescaled: DecimalU64 = d.rescale().unwrap(); - assert_eq!(rescaled.unscaled.to_string(), "5000"); + #[case(decimal::(50), "50")] + #[case(decimal::(12345), "12345")] + fn should_not_rescale_with_same_base_unchecked(#[case] d: DecimalU64, #[case] expected: String) { + let res: DecimalU64 = unsafe{d.rescale_unchecked()}; + assert_eq!(res.unscaled.to_string(), expected); } #[rstest] - fn test_rescale_downscale_exact() { - let d: DecimalU64 = decimal(1200); - let rescaled: DecimalU64 = d.rescale().unwrap(); - assert_eq!(rescaled.unscaled.to_string(), "12"); + #[case(decimal::(50), "50")] + #[case(decimal::(12345), "12345")] + fn should_not_rescale_with_same_base_checked(#[case] d: DecimalU64, #[case] expected: String) { + let res: DecimalU64 = d.rescale().unwrap(); + assert_eq!(res.unscaled.to_string(), expected); } #[rstest] - fn test_rescale_zero() { - let d: DecimalU64 = decimal(0); - let rescaled: DecimalU64 = d.rescale().unwrap(); - assert_eq!(rescaled.unscaled.to_string(), "0"); + #[case(decimal::(12345))] + #[case(decimal::(123400))] + // Only check this property for checked as unchecked allows precision loss + fn should_round_trip_invariant_checked(#[case] d: DecimalU64) { + let res_up: DecimalU64 = d.rescale().unwrap(); + let res_down: DecimalU64 = res_up.rescale().unwrap(); + if res_down.unscaled != 0 { + assert_eq!(res_down.unscaled, d.unscaled); + } } #[rstest] - fn test_rescale_identity_scale() { - let d: DecimalU64 = decimal(123); - let rescaled: DecimalU64 = d.rescale().unwrap(); - assert_eq!(rescaled.unscaled.to_string(), "123"); + #[case(decimal::(1234), "12")] + #[case(decimal::(1200), "12")] + #[case(decimal::(5_000_001), "500")] + #[case(decimal::(5_000_001), "5")] + fn should_trunacate_downscale(#[case] d: DecimalU64, #[case] expected: String) { + let res: DecimalU64 = unsafe { d.rescale_unchecked() }; + assert_eq!(res.unscaled.to_string(), expected); } #[rstest] - #[case(decimal::(1234))] // 0.1234 -> truncation - #[case(decimal::(100001))] // tiny truncation - fn test_rescale_downscale_precision_loss(#[case] d: DecimalU64) { - let err = d.rescale::().unwrap_err(); - match err { - Error::PrecisionLoss(msg) => { - assert!(msg.contains("Truncated")); - } - _ => panic!("Expected PrecisionLoss"), - } + #[case(decimal::(0))] + #[case(decimal::(0))] + #[case(decimal::(0))] + #[case(decimal::(0))] + #[case(decimal::(0))] + #[case(decimal::(0))] + #[case(decimal::(0))] + #[case(decimal::(0))] + #[case(decimal::(0))] + // Tests to ensure upscales + downscales where unscaled value is 0 will always be 0 + fn should_rescale_zero_all_scales(#[case] d: DecimalU64) { + let res_checked: DecimalU64 = d.rescale().unwrap(); + let res_unchecked: DecimalU64 = unsafe { d.rescale_unchecked() }; + assert_eq!(res_checked.unscaled.to_string(), "0"); + assert_eq!(res_unchecked.unscaled.to_string(), "0"); + + let res_checked_down: DecimalU64 = d.rescale().unwrap(); + let res_unchecked_down: DecimalU64 = unsafe { d.rescale_unchecked() }; + assert_eq!(res_checked_down.unscaled.to_string(), "0"); + assert_eq!(res_unchecked_down.unscaled.to_string(), "0"); + } + + #[rstest] + #[case(decimal::(12000000), "12")] + #[case(decimal::(123400), "1234")] + #[case(decimal::(50000), "5")] + fn should_downscale_at_scale_boundary_checked_unchecked(#[case] d: DecimalU64, #[case] expected: &str) { + + let res_checked: DecimalU64 = d.rescale().unwrap(); + let res_unchecked: DecimalU64 = unsafe { d.rescale_unchecked() }; + + assert_eq!(res_checked.unscaled.to_string(), expected); + assert_eq!(res_unchecked.unscaled.to_string(), expected); } #[rstest] - fn test_rescale_downscale_to_zero() { - let d: DecimalU64 = decimal(1); // 0.000001 - let err = d.rescale::().unwrap_err(); - assert!(matches!(err, Error::PrecisionLoss(_))); + #[case(decimal::(9999999999))] + #[case(decimal::(999999))] + #[case(decimal::(9999))] + #[should_panic(expected = "PrecisionLoss")] + fn should_return_precision_loss(#[case] d: DecimalU64) { + let _: DecimalU64 = d.rescale().unwrap(); + } + + #[rstest] + #[case(decimal::(u64::MAX / 10 + 1))] + #[should_panic(expected = "Overflow")] + fn should_upscale_checked_overflow(#[case] d: DecimalU64) { + let _res: DecimalU64 = d.rescale().unwrap(); } #[rstest] - fn test_rescale_upscale_overflow() { - // overflow when multiplied by 10^(6-2) = 10_000 - let d: DecimalU64 = decimal(u64::MAX / 1000); - let res = d.rescale::(); - assert!(matches!(res, Err(Error::Overflow(_)))); + #[case(decimal::(u64::MAX / 10))] + fn should_upscale_checked_safe(#[case] d: DecimalU64) { + let res: DecimalU64 = d.rescale().unwrap(); + assert_eq!(res.unscaled.to_string(), ((d.unscaled * 10) as u64).to_string()); } #[rstest] - fn test_rescale_upscale_overflow_boundary() { - let d: DecimalU64 = decimal(u64::MAX / 100); - let res = d.rescale::(); - assert!(matches!(res, Err(Error::Overflow(_)))); + #[case(decimal::(u64::MAX))] + fn should_upscale_u64_boundary_unchecked(#[case] d: DecimalU64) { + let res: DecimalU64 = unsafe { d.rescale_unchecked() }; + let expected = (d.unscaled as u128 * 100).min(u64::MAX as u128) as u64; + assert_eq!(res.unscaled.to_string(), expected.to_string()); } + + #[rstest] + #[case(decimal::(100))] + fn should_downscale_checked_exact(#[case] d: DecimalU64) { + let res: DecimalU64 = d.rescale().unwrap(); + let expected = d.unscaled / 100; + assert_eq!(res.unscaled.to_string(), expected.to_string()); + } + + + #[rstest] + #[case(decimal::(101))] + #[should_panic(expected = "PrecisionLoss")] + fn should_downscale_checked_precision_loss(#[case] d: DecimalU64) { + let _res: DecimalU64 = d.rescale().unwrap(); + } + + #[rstest] + #[case(decimal::((u64::MAX / 100) * 100))] + fn should_downscale_checked_large_exact(#[case] d: DecimalU64) { + let res: DecimalU64 = d.rescale().unwrap(); + let expected = d.unscaled / 100; + assert_eq!(res.unscaled.to_string(), expected.to_string()); + } + + #[rstest] + #[case(decimal::(u64::MAX))] + #[should_panic(expected = "PrecisionLoss")] + fn should_downscale_checked_large_remainder(#[case] d: DecimalU64) { + let _res: DecimalU64 = d.rescale().unwrap(); + } + + #[rstest] + #[case(decimal::(101))] + fn should_downscale_unchecked_u64_boundary(#[case] d: DecimalU64) { + let res: DecimalU64 = unsafe { d.rescale_unchecked() }; + let expected = d.unscaled / 100; + assert_eq!(res.unscaled.to_string(), expected.to_string()); + } + } From 4d343516450d88e84121ccf2031bceebc1254a33 Mon Sep 17 00:00:00 2001 From: BDaws04 Date: Wed, 11 Feb 2026 13:26:31 +0000 Subject: [PATCH 8/9] cargo fmt --- src/lib.rs | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f8c89c3..74fefad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -498,10 +498,10 @@ mod tests { // tests for rescale checked and unchecked #[rstest] - #[case(decimal::(0), "0")] - #[case(decimal::(1), "10000000")] - #[case(decimal::(12), "12000000")] - #[case(decimal::(1234), "12340000")] + #[case(decimal::(0), "0")] + #[case(decimal::(1), "10000000")] + #[case(decimal::(12), "12000000")] + #[case(decimal::(1234), "12340000")] #[case(decimal::(999999), "99999900")] fn should_upscale_checked(#[case] d: DecimalU64, #[case] expected: String) { let rescaled: DecimalU64 = d.rescale().unwrap(); @@ -509,10 +509,10 @@ mod tests { } #[rstest] - #[case(decimal::(0), "0")] - #[case(decimal::(1200), "1200")] - #[case(decimal::(123400), "1234")] - #[case(decimal::(5000000), "500")] + #[case(decimal::(0), "0")] + #[case(decimal::(1200), "1200")] + #[case(decimal::(123400), "1234")] + #[case(decimal::(5000000), "500")] #[case(decimal::(99000000), "99")] fn should_downscale_checked(#[case] d: DecimalU64, #[case] expected: String) { let rescaled: DecimalU64 = d.rescale().unwrap(); @@ -545,7 +545,7 @@ mod tests { #[case(decimal::(50), "50")] #[case(decimal::(12345), "12345")] fn should_not_rescale_with_same_base_unchecked(#[case] d: DecimalU64, #[case] expected: String) { - let res: DecimalU64 = unsafe{d.rescale_unchecked()}; + let res: DecimalU64 = unsafe { d.rescale_unchecked() }; assert_eq!(res.unscaled.to_string(), expected); } @@ -603,14 +603,16 @@ mod tests { } #[rstest] - #[case(decimal::(12000000), "12")] - #[case(decimal::(123400), "1234")] - #[case(decimal::(50000), "5")] - fn should_downscale_at_scale_boundary_checked_unchecked(#[case] d: DecimalU64, #[case] expected: &str) { - + #[case(decimal::(12000000), "12")] + #[case(decimal::(123400), "1234")] + #[case(decimal::(50000), "5")] + fn should_downscale_at_scale_boundary_checked_unchecked( + #[case] d: DecimalU64, + #[case] expected: &str, + ) { let res_checked: DecimalU64 = d.rescale().unwrap(); let res_unchecked: DecimalU64 = unsafe { d.rescale_unchecked() }; - + assert_eq!(res_checked.unscaled.to_string(), expected); assert_eq!(res_unchecked.unscaled.to_string(), expected); } @@ -622,17 +624,17 @@ mod tests { #[should_panic(expected = "PrecisionLoss")] fn should_return_precision_loss(#[case] d: DecimalU64) { let _: DecimalU64 = d.rescale().unwrap(); - } + } #[rstest] - #[case(decimal::(u64::MAX / 10 + 1))] + #[case(decimal::(u64::MAX / 10 + 1))] #[should_panic(expected = "Overflow")] fn should_upscale_checked_overflow(#[case] d: DecimalU64) { let _res: DecimalU64 = d.rescale().unwrap(); } #[rstest] - #[case(decimal::(u64::MAX / 10))] + #[case(decimal::(u64::MAX / 10))] fn should_upscale_checked_safe(#[case] d: DecimalU64) { let res: DecimalU64 = d.rescale().unwrap(); assert_eq!(res.unscaled.to_string(), ((d.unscaled * 10) as u64).to_string()); @@ -654,16 +656,15 @@ mod tests { assert_eq!(res.unscaled.to_string(), expected.to_string()); } - #[rstest] - #[case(decimal::(101))] + #[case(decimal::(101))] #[should_panic(expected = "PrecisionLoss")] fn should_downscale_checked_precision_loss(#[case] d: DecimalU64) { let _res: DecimalU64 = d.rescale().unwrap(); } #[rstest] - #[case(decimal::((u64::MAX / 100) * 100))] + #[case(decimal::((u64::MAX / 100) * 100))] fn should_downscale_checked_large_exact(#[case] d: DecimalU64) { let res: DecimalU64 = d.rescale().unwrap(); let expected = d.unscaled / 100; @@ -678,11 +679,10 @@ mod tests { } #[rstest] - #[case(decimal::(101))] + #[case(decimal::(101))] fn should_downscale_unchecked_u64_boundary(#[case] d: DecimalU64) { let res: DecimalU64 = unsafe { d.rescale_unchecked() }; - let expected = d.unscaled / 100; + let expected = d.unscaled / 100; assert_eq!(res.unscaled.to_string(), expected.to_string()); } - } From 573d39dace773578e1c3f00a80090c43a3bd1288 Mon Sep 17 00:00:00 2001 From: BDaws04 Date: Fri, 20 Feb 2026 19:24:15 +0000 Subject: [PATCH 9/9] update tests --- src/lib.rs | 334 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 193 insertions(+), 141 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 74fefad..d8c94bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -127,25 +127,32 @@ impl DecimalU64 { /// - Any precision loss caused by downscaling is acceptable pub unsafe fn rescale_unchecked(&self) -> DecimalU64 { if T::SCALE >= S::SCALE { + // Upscale: multiply let factor = 10u64.pow((T::SCALE - S::SCALE) as u32); DecimalU64::::from_raw(self.unscaled.saturating_mul(factor)) } else { + // Downscale: divide (truncate) let factor = 10u64.pow((S::SCALE - T::SCALE) as u32); DecimalU64::::from_raw(self.unscaled / factor) } } + /// Checked rescale: returns Overflow or PrecisionLoss errors pub fn rescale(&self) -> Result, self::Error> { if T::SCALE >= S::SCALE { + // Upscale let factor = 10u64 .checked_pow((T::SCALE - S::SCALE) as u32) .ok_or_else(|| Error::Overflow(self.unscaled.to_string()))?; + let unscaled = self .unscaled .checked_mul(factor) .ok_or_else(|| Error::Overflow(self.unscaled.to_string()))?; + Ok(DecimalU64::::from_raw(unscaled)) } else { + // Downscale let factor = 10u64 .checked_pow((S::SCALE - T::SCALE) as u32) .ok_or_else(|| Error::Overflow(self.unscaled.to_string()))?; @@ -154,6 +161,7 @@ impl DecimalU64 { let remainder = self.unscaled % factor; if remainder != 0 { + // Precision loss occurred Err(Error::PrecisionLoss(format!( "Truncated {} fractional digits when rescaling {} -> {}", S::SCALE - T::SCALE, @@ -495,194 +503,238 @@ mod tests { fn decimal(unscaled: u64) -> DecimalU64 { DecimalU64::::from_raw(unscaled) } + // ===== RESCALE TESTS ===== - // tests for rescale checked and unchecked - #[rstest] - #[case(decimal::(0), "0")] - #[case(decimal::(1), "10000000")] - #[case(decimal::(12), "12000000")] - #[case(decimal::(1234), "12340000")] - #[case(decimal::(999999), "99999900")] - fn should_upscale_checked(#[case] d: DecimalU64, #[case] expected: String) { - let rescaled: DecimalU64 = d.rescale().unwrap(); - assert_eq!(rescaled.unscaled.to_string(), expected); + // Helper to parse a decimal string at a specific scale + fn parse_decimal(s: &str) -> DecimalU64 { + DecimalU64::::from_str(s).unwrap() } - #[rstest] - #[case(decimal::(0), "0")] - #[case(decimal::(1200), "1200")] - #[case(decimal::(123400), "1234")] - #[case(decimal::(5000000), "500")] - #[case(decimal::(99000000), "99")] - fn should_downscale_checked(#[case] d: DecimalU64, #[case] expected: String) { - let rescaled: DecimalU64 = d.rescale().unwrap(); - assert_eq!(rescaled.unscaled.to_string(), expected); - } + // Generic rescale test for checked rescale (exact) + fn rescale(s: &'static str) { + let s1 = parse_decimal::(s); + let s2 = s1.rescale::().unwrap(); - #[rstest] - #[case(decimal::(0), "0")] - #[case(decimal::(1), "10000000")] - #[case(decimal::(12), "12000000")] - #[case(decimal::(1234), "12340000")] - #[case(decimal::(999999), "99999900")] - fn should_upscale_unchecked(#[case] d: DecimalU64, #[case] expected: String) { - let rescaled: DecimalU64 = unsafe { d.rescale_unchecked() }; - assert_eq!(rescaled.unscaled.to_string(), expected); + // Compare decimal strings ignoring trailing zeros + assert_eq!( + s1.to_string().trim_end_matches('0').trim_end_matches('.'), + s2.to_string().trim_end_matches('0').trim_end_matches('.') + ); } - #[rstest] - #[case(decimal::(0), "0")] - #[case(decimal::(1200), "1200")] - #[case(decimal::(123400), "1234")] - #[case(decimal::(5000000), "500")] - #[case(decimal::(99999900), "99")] - fn should_downscale_unchecked(#[case] d: DecimalU64, #[case] expected: String) { - let rescaled: DecimalU64 = unsafe { d.rescale_unchecked() }; - assert_eq!(rescaled.unscaled.to_string(), expected); + // Generic unchecked rescale test - compare the actual decimal value + fn rescale_unchecked(s: &'static str, expected: &str) { + let d = parse_decimal::(s); + let res: DecimalU64 = unsafe { d.rescale_unchecked() }; + assert_eq!(res.to_string(), expected); // Compare Display output, not unscaled } - #[rstest] - #[case(decimal::(50), "50")] - #[case(decimal::(12345), "12345")] - fn should_not_rescale_with_same_base_unchecked(#[case] d: DecimalU64, #[case] expected: String) { - let res: DecimalU64 = unsafe { d.rescale_unchecked() }; - assert_eq!(res.unscaled.to_string(), expected); + // Generic checked rescale test - compare the actual decimal value + fn rescale_checked(s: &'static str, expected: &str) { + let d = parse_decimal::(s); + let res: DecimalU64 = d.rescale().unwrap(); + assert_eq!(res.to_string(), expected); // Compare Display output, not unscaled } + // ------------------------- + // RESCALE UP (checked) + // ------------------------- #[rstest] - #[case(decimal::(50), "50")] - #[case(decimal::(12345), "12345")] - fn should_not_rescale_with_same_base_checked(#[case] d: DecimalU64, #[case] expected: String) { - let res: DecimalU64 = d.rescale().unwrap(); - assert_eq!(res.unscaled.to_string(), expected); - } - + #[case("0")] + #[case("1")] + #[case("0.01")] + #[case("1.25")] + #[case("123.45")] + fn rescale_up(#[case] s: &'static str) { + rescale::(s); + rescale::(s); + rescale::(s); + rescale::(s); + } + + // ------------------------- + // RESCALE DOWN (checked) + // ------------------------- #[rstest] - #[case(decimal::(12345))] - #[case(decimal::(123400))] - // Only check this property for checked as unchecked allows precision loss - fn should_round_trip_invariant_checked(#[case] d: DecimalU64) { - let res_up: DecimalU64 = d.rescale().unwrap(); - let res_down: DecimalU64 = res_up.rescale().unwrap(); - if res_down.unscaled != 0 { - assert_eq!(res_down.unscaled, d.unscaled); - } + #[case("0")] + #[case("1")] + #[case("10")] + #[case("123")] + #[case("1.20")] + #[case("123.450")] + fn rescale_down(#[case] s: &'static str) { + rescale::(s); + rescale::(s); + rescale::(s); + rescale::(s); + } + + // ------------------------- + // UPSCALE UNCHECKED + // ------------------------- + #[rstest] + #[case("0", "0.00000000")] + #[case("1", "1.00000000")] // U0 -> U8: 1 becomes 1.00000000 + #[case("12", "12.00000000")] // U0 -> U8: 12 becomes 12.00000000 + #[case("1234", "1234.00000000")] // U0 -> U8: 1234 becomes 1234.00000000 + #[case("999999", "999999.00000000")] // U0 -> U8: 999999 becomes 999999.00000000 + fn should_upscale_unchecked_u0_to_u8(#[case] s: &'static str, #[case] expected: &str) { + rescale_unchecked::(s, expected); } #[rstest] - #[case(decimal::(1234), "12")] - #[case(decimal::(1200), "12")] - #[case(decimal::(5_000_001), "500")] - #[case(decimal::(5_000_001), "5")] - fn should_trunacate_downscale(#[case] d: DecimalU64, #[case] expected: String) { - let res: DecimalU64 = unsafe { d.rescale_unchecked() }; - assert_eq!(res.unscaled.to_string(), expected); + #[case("1.23", "1.23000000")] // U2 -> U8: 1.23 becomes 1.23000000 + #[case("12.34", "12.34000000")] // U2 -> U8: 12.34 becomes 12.34000000 + #[case("123.45", "123.45000000")] // U2 -> U8: 123.45 becomes 123.45000000 + #[case("999.99", "999.99000000")] // U2 -> U8: 999.99 becomes 999.99000000 + fn should_upscale_unchecked_u2_to_u8(#[case] s: &'static str, #[case] expected: &str) { + rescale_unchecked::(s, expected); } #[rstest] - #[case(decimal::(0))] - #[case(decimal::(0))] - #[case(decimal::(0))] - #[case(decimal::(0))] - #[case(decimal::(0))] - #[case(decimal::(0))] - #[case(decimal::(0))] - #[case(decimal::(0))] - #[case(decimal::(0))] - // Tests to ensure upscales + downscales where unscaled value is 0 will always be 0 - fn should_rescale_zero_all_scales(#[case] d: DecimalU64) { - let res_checked: DecimalU64 = d.rescale().unwrap(); - let res_unchecked: DecimalU64 = unsafe { d.rescale_unchecked() }; - assert_eq!(res_checked.unscaled.to_string(), "0"); - assert_eq!(res_unchecked.unscaled.to_string(), "0"); - - let res_checked_down: DecimalU64 = d.rescale().unwrap(); - let res_unchecked_down: DecimalU64 = unsafe { d.rescale_unchecked() }; - assert_eq!(res_checked_down.unscaled.to_string(), "0"); - assert_eq!(res_unchecked_down.unscaled.to_string(), "0"); + #[case("1.2345", "1.23450000")] // U4 -> U8: 1.2345 becomes 1.23450000 + #[case("12.3456", "12.34560000")] // U4 -> U8: 12.3456 becomes 12.34560000 + fn should_upscale_unchecked_u4_to_u8(#[case] s: &'static str, #[case] expected: &str) { + rescale_unchecked::(s, expected); } + // ------------------------- + // DOWNSCALE UNCHECKED + // ------------------------- #[rstest] - #[case(decimal::(12000000), "12")] - #[case(decimal::(123400), "1234")] - #[case(decimal::(50000), "5")] - fn should_downscale_at_scale_boundary_checked_unchecked( - #[case] d: DecimalU64, - #[case] expected: &str, - ) { - let res_checked: DecimalU64 = d.rescale().unwrap(); - let res_unchecked: DecimalU64 = unsafe { d.rescale_unchecked() }; - - assert_eq!(res_checked.unscaled.to_string(), expected); - assert_eq!(res_unchecked.unscaled.to_string(), expected); + #[case("1.20000000", "1.20")] // U8 -> U2: 1.20000000 becomes 1.20 + #[case("123.40000000", "123.40")] // U8 -> U2: 123.40000000 becomes 123.40 + #[case("0.50000000", "0.50")] // U8 -> U2: 0.50000000 becomes 0.50 + #[case("0.99000000", "0.99")] // U8 -> U2: 0.99000000 becomes 0.99 + #[case("123.45678900", "123.45")] // U8 -> U2: 123.45678900 becomes 123.45 (truncated) + fn should_downscale_unchecked_u8_to_u2(#[case] s: &'static str, #[case] expected: &str) { + rescale_unchecked::(s, expected); } #[rstest] - #[case(decimal::(9999999999))] - #[case(decimal::(999999))] - #[case(decimal::(9999))] - #[should_panic(expected = "PrecisionLoss")] - fn should_return_precision_loss(#[case] d: DecimalU64) { - let _: DecimalU64 = d.rescale().unwrap(); + #[case("1.20000000", "1.200")] // U8 -> U3: 1.20000000 becomes 1.200 + #[case("123.45678900", "123.456")] // U8 -> U3: 123.45678900 becomes 123.456 (truncated) + fn should_downscale_unchecked_u8_to_u3(#[case] s: &'static str, #[case] expected: &str) { + rescale_unchecked::(s, expected); } #[rstest] - #[case(decimal::(u64::MAX / 10 + 1))] - #[should_panic(expected = "Overflow")] - fn should_upscale_checked_overflow(#[case] d: DecimalU64) { - let _res: DecimalU64 = d.rescale().unwrap(); + #[case("1.20000000", "1.2")] // U8 -> U1: 1.20000000 becomes 1.2 + #[case("123.40000000", "123.4")] // U8 -> U1: 123.40000000 becomes 123.4 + fn should_downscale_unchecked_u8_to_u1(#[case] s: &'static str, #[case] expected: &str) { + rescale_unchecked::(s, expected); } #[rstest] - #[case(decimal::(u64::MAX / 10))] - fn should_upscale_checked_safe(#[case] d: DecimalU64) { - let res: DecimalU64 = d.rescale().unwrap(); - assert_eq!(res.unscaled.to_string(), ((d.unscaled * 10) as u64).to_string()); + #[case("1.20000000", "1")] // U8 -> U0: 1.20000000 becomes 1 + #[case("123.40000000", "123")] // U8 -> U0: 123.40000000 becomes 123 + #[case("0.50000000", "0")] // U8 -> U0: 0.50000000 becomes 0 (truncated) + fn should_downscale_unchecked_u8_to_u0(#[case] s: &'static str, #[case] expected: &str) { + rescale_unchecked::(s, expected); } + // -------------------------- + // SAME BASE (unchecked) + // -------------------------- #[rstest] - #[case(decimal::(u64::MAX))] - fn should_upscale_u64_boundary_unchecked(#[case] d: DecimalU64) { + #[case("50")] + #[case("12345")] + fn should_not_rescale_with_same_base_unchecked(#[case] s: &str) { + let d = DecimalU64::::from_str(s).unwrap(); let res: DecimalU64 = unsafe { d.rescale_unchecked() }; - let expected = (d.unscaled as u128 * 100).min(u64::MAX as u128) as u64; - assert_eq!(res.unscaled.to_string(), expected.to_string()); + assert_eq!(res.to_string(), d.to_string()); } + // ------------------------- + // SAME BASE (checked) + // ------------------------- #[rstest] - #[case(decimal::(100))] - fn should_downscale_checked_exact(#[case] d: DecimalU64) { - let res: DecimalU64 = d.rescale().unwrap(); - let expected = d.unscaled / 100; - assert_eq!(res.unscaled.to_string(), expected.to_string()); + #[case("50", "50")] + #[case("12345", "12345")] + fn should_not_rescale_with_same_base(#[case] s: &'static str, #[case] expected: &str) { + let d = DecimalU64::::from_str(s).unwrap(); + let res = d.rescale::().unwrap(); + + // Compare decimal strings ignoring trailing zeros + assert_eq!(res.to_string().trim_end_matches('0').trim_end_matches('.'), expected); } + // ------------------------- + // ROUND-TRIP INVARIANT + // ------------------------- #[rstest] - #[case(decimal::(101))] - #[should_panic(expected = "PrecisionLoss")] - fn should_downscale_checked_precision_loss(#[case] d: DecimalU64) { - let _res: DecimalU64 = d.rescale().unwrap(); + #[case("12345")] + #[case("123400")] + fn should_round_trip_invariant(#[case] s: &'static str) { + let d = DecimalU64::::from_str(s).unwrap(); + let up: DecimalU64 = d.rescale().unwrap(); + let down: DecimalU64 = up.rescale().unwrap(); + + // Compare decimal values, not unscaled + assert_eq!(d.to_string().trim_end_matches('0'), down.to_string().trim_end_matches('0')); } + // ------------------------- + // TRUNCATE DOWNSCALE (unchecked) + // ------------------------- #[rstest] - #[case(decimal::((u64::MAX / 100) * 100))] - fn should_downscale_checked_large_exact(#[case] d: DecimalU64) { - let res: DecimalU64 = d.rescale().unwrap(); - let expected = d.unscaled / 100; - assert_eq!(res.unscaled.to_string(), expected.to_string()); + #[case("123.45678900", "123.45")] // U8 -> U2: truncate to 2 decimals + #[case("500.12345600", "500.12")] // U8 -> U2: truncate to 2 decimals + #[case("0.99999900", "0.99")] // U8 -> U2: truncate to 2 decimals + fn should_truncate_downscale(#[case] s: &str, #[case] expected: &str) { + let d = DecimalU64::::from_str(s).unwrap(); + let res: DecimalU64 = unsafe { d.rescale_unchecked() }; + assert_eq!(res.to_string(), expected); } + // ------------------------- + // ZERO ACROSS SCALES + // ------------------------- #[rstest] - #[case(decimal::(u64::MAX))] - #[should_panic(expected = "PrecisionLoss")] - fn should_downscale_checked_large_remainder(#[case] d: DecimalU64) { - let _res: DecimalU64 = d.rescale().unwrap(); + #[case("0")] + fn should_rescale_zero_all_scales(#[case] s: &'static str) { + let d = DecimalU64::::from_str(s).unwrap(); + let up_checked: DecimalU64 = d.rescale().unwrap(); + let up_unchecked: DecimalU64 = unsafe { d.rescale_unchecked() }; + let down_checked: DecimalU64 = up_checked.rescale().unwrap(); + let down_unchecked: DecimalU64 = unsafe { up_unchecked.rescale_unchecked() }; + + assert_eq!(up_checked.to_string(), "0.00000000"); + assert_eq!(up_unchecked.to_string(), "0.00000000"); + assert_eq!(down_checked.to_string(), "0"); + assert_eq!(down_unchecked.to_string(), "0"); + } + + // ------------------------- + // PRECISION LOSS (checked) + // ------------------------- + #[test] + fn should_error_on_precision_loss() { + let d = DecimalU64::::from_str("101.2038").unwrap(); // 4 decimal places + let result: Result, Error> = d.rescale(); // Downscale to 2 decimals + + assert!(result.is_err()); + match result { + Err(Error::PrecisionLoss(msg)) => { + assert!(msg.contains("Truncated") || msg.contains("precision")); + } + _ => panic!("Expected PrecisionLoss error"), + } } - #[rstest] - #[case(decimal::(101))] - fn should_downscale_unchecked_u64_boundary(#[case] d: DecimalU64) { - let res: DecimalU64 = unsafe { d.rescale_unchecked() }; - let expected = d.unscaled / 100; - assert_eq!(res.unscaled.to_string(), expected.to_string()); + // ------------------------- + // OVERFLOW (checked) + // ------------------------- + #[test] + fn should_error_on_overflow() { + // Try to upscale MAX value at U0 to U1 (would multiply by 10, causing overflow) + let d = DecimalU64::::MAX; + let result: Result, Error> = d.rescale(); + + assert!(result.is_err()); + match result { + Err(Error::Overflow(_)) => {} + _ => panic!("Expected Overflow error"), + } } }