Repository: sharkdp/numbat (rust)

Issue:
assert_eq: Better error messages
Consider this example:
```
>>> assert_eq(2m, 201cm, 0.1cm)
error: Assertion failed
  ┌─ <input:2>:1:15
  │
1 │ assert_eq(2m, 201cm, 0.1cm)
  │           --  ^^^^^ 201 cm
  │           │    
  │           2 m
  │
  = Assertion failed because the following two quantities differ by more than 0.1 cm:
      2 m
      201 cm
```
This is not very helpful, since we show the LHS/RHS values in different units.

And if the contrived example is not enough, here's a real world error message that I got today:
```
>>> assert_eq(-77° + 0′ + 32″, -77.0089°, 1e-4°)
error: Assertion failed
  ┌─ <input:1>:1:28
  │
1 │ assert_eq(-77° + 0′ + 32″, -77.0089°, 1e-4°)
  │           ---------------  ^^^^^^^^^ -77.0089°
  │           │                 
  │           -1.34375
  │
  = Assertion failed because the following two quantities differ by more than 0.00000174533:
      -1.34375
      -77.0089°
```


We certainly want to do a unit conversion here (to the same unit that is also used for the actual comparison, I would guess the LHS unit). Maybe we even want to show a comparison in the two possible units (the LHS unit and the RHS unit)? 

Pull request:
Improve error message for assert_eq/3
Closes #505

Improves the error message for assert_eq/3 using consistent units for the input comparands (quantities). Before the change, the error message may have caused confusion as different units are used for the comparands.

Converting everything to the units of epsilon is intuitive and allows a user to see the difference in the error message in different units easily by changing the unit of epsilon without having to mess with the lhs or rhs expressions.

With this change, assert_eq/3 will convert comparands to the units of epsilon before comparison and show original units where possible in parentheses.

Example error message after this change:

```
>>> assert_eq(2m, 201cm, 0.1cm to mm)
error: Assertion failed
  ┌─ <input:1>:1:15
  │
1 │ assert_eq(2m, 201cm, 0.1cm to mm)
  │           --  ^^^^^ 2010.0 mm (201 cm)
  │           │
  │           2000 mm (2 m)
  │
  = Assertion failed because the following two quantities differ by 10.0 mm, which is more than or equal to 1 mm:
      2000 mm (2 m)
      2010.0 mm (201 cm)
```

Example error message before this change:

```
>>> assert_eq(2m, 201cm, 0.1cm to mm)
error: Assertion failed
  ┌─ <input:1>:1:15
  │
1 │ assert_eq(2m, 201cm, 0.1cm to mm)
  │           --  ^^^^^ 201 cm
  │           │
  │           2 m
  │
  = Assertion failed because the following two quantities differ by more than 1 mm:
      2 m
      201 cm
 ```

Patch:
diff --git a/book/src/procedures.md b/book/src/procedures.md
index a524418..596cda2 100644
--- a/book/src/procedures.md
+++ b/book/src/procedures.md
@@ -47,7 +47,9 @@ assert_eq(q1, q2, ε)
 ```
 
 where the first version tests for exact equality while the second version tests for approximate
-equality \\( |q_1-q_2| < \epsilon \\) with a specified accuracy of \\( \epsilon \\). For example:
+equality \\( |q_1-q_2| <= \epsilon \\) with a specified accuracy of \\( \epsilon \\).
+Note that the input quantities are converted to the units of \\( \epsilon \\) before comparison.
+For example:
 
 ```nbt
 assert_eq(2 + 3, 5)
diff --git a/examples/runtime_error/assert_eq_3.nbt b/examples/runtime_error/assert_eq_3.nbt
new file mode 100644
index 0000000..d3c3b47
--- /dev/null
+++ b/examples/runtime_error/assert_eq_3.nbt
@@ -0,0 +1,2 @@
+# fail approximate equality test with epsilon in units that are different from either comparand
+assert_eq(2m, 201cm, 0.1cm to mm)
diff --git a/numbat/src/diagnostic.rs b/numbat/src/diagnostic.rs
index c14b9bf..9689553 100644
--- a/numbat/src/diagnostic.rs
+++ b/numbat/src/diagnostic.rs
@@ -518,16 +518,20 @@ impl ErrorDiagnostic for RuntimeError {
                     ])
                     .with_notes(vec![inner])]
             }
-            RuntimeError::AssertEq3Failed(span_lhs, lhs, span_rhs, rhs, _) => {
+            RuntimeError::AssertEq3Failed(assert_eq3_error) => {
+                let (lhs, rhs) = assert_eq3_error.fmt_comparands();
+
                 vec![Diagnostic::error()
                     .with_message("Assertion failed")
                     .with_labels(vec![
-                        span_lhs
+                        assert_eq3_error
+                            .span_lhs
                             .diagnostic_label(LabelStyle::Secondary)
-                            .with_message(format!("{lhs}")),
-                        span_rhs
+                            .with_message(lhs),
+                        assert_eq3_error
+                            .span_rhs
                             .diagnostic_label(LabelStyle::Primary)
-                            .with_message(format!("{rhs}")),
+                            .with_message(rhs),
                     ])
                     .with_notes(vec![format!("{self:#}")])]
             }
diff --git a/numbat/src/ffi/procedures.rs b/numbat/src/ffi/procedures.rs
index 12a70fc..1b6bffc 100644
--- a/numbat/src/ffi/procedures.rs
+++ b/numbat/src/ffi/procedures.rs
@@ -4,8 +4,8 @@ use std::sync::OnceLock;
 
 use super::macros::*;
 use crate::{
-    ast::ProcedureKind, ffi::ControlFlow, pretty_print::PrettyPrint, span::Span, value::Value,
-    vm::ExecutionContext, RuntimeError,
+    ast::ProcedureKind, ffi::ControlFlow, interpreter::assert_eq_3::AssertEq3Error,
+    pretty_print::PrettyPrint, span::Span, value::Value, vm::ExecutionContext, RuntimeError,
 };
 
 use super::{Args, Callable, ForeignFunction};
@@ -107,29 +107,42 @@ fn assert_eq(_: &mut ExecutionContext, mut args: Args, arg_spans: Vec<Span>) ->
             error
         }
     } else {
-        let lhs = quantity_arg!(args);
-        let rhs = quantity_arg!(args);
-        let result = &lhs - &rhs;
+        let lhs_original = quantity_arg!(args);
+        let rhs_original = quantity_arg!(args);
         let eps = quantity_arg!(args);
 
+        let lhs_converted = lhs_original.convert_to(eps.unit());
+        let lhs_converted = match lhs_converted {
+            Err(e) => return ControlFlow::Break(RuntimeError::QuantityError(e)),
+            Ok(q) => q,
+        };
+        let rhs_converted = rhs_original.convert_to(eps.unit());
+        let rhs_converted = match rhs_converted {
+            Err(e) => return ControlFlow::Break(RuntimeError::QuantityError(e)),
+            Ok(q) => q,
+        };
+
+        let result = &lhs_converted - &rhs_converted;
+
         match result {
-            Ok(diff) => match diff.convert_to(eps.unit()) {
-                Err(e) => ControlFlow::Break(RuntimeError::QuantityError(e)),
-                Ok(diff_converted) => {
-                    if diff_converted.unsafe_value().to_f64().abs() < eps.unsafe_value().to_f64() {
-                        ControlFlow::Continue(())
-                    } else {
-                        ControlFlow::Break(RuntimeError::AssertEq3Failed(
-                            span_lhs,
-                            lhs.clone(),
-                            span_rhs,
-                            rhs.clone(),
-                            eps.clone(),
-                        ))
-                    }
-                }
-            },
             Err(e) => ControlFlow::Break(RuntimeError::QuantityError(e)),
+            Ok(diff) => {
+                let diff_abs = diff.abs();
+                if diff_abs <= eps {
+                    ControlFlow::Continue(())
+                } else {
+                    ControlFlow::Break(RuntimeError::AssertEq3Failed(AssertEq3Error {
+                        span_lhs,
+                        lhs_original,
+                        lhs_converted,
+                        span_rhs,
+                        rhs_original,
+                        rhs_converted,
+                        eps,
+                        diff_abs,
+                    }))
+                }
+            }
         }
     }
 }
diff --git a/numbat/src/interpreter/assert_eq_3.rs b/numbat/src/interpreter/assert_eq_3.rs
new file mode 100644
index 0000000..414e570
--- /dev/null
+++ b/numbat/src/interpreter/assert_eq_3.rs
@@ -0,0 +1,218 @@
+use crate::{quantity::Quantity, span::Span};
+use std::fmt::Display;
+use thiserror::Error;
+
+#[derive(Debug, Clone, Error, PartialEq, Eq)]
+pub struct AssertEq3Error {
+    pub span_lhs: Span,
+    pub lhs_original: Quantity,
+    pub lhs_converted: Quantity,
+    pub span_rhs: Span,
+    pub rhs_original: Quantity,
+    pub rhs_converted: Quantity,
+    pub eps: Quantity,
+    pub diff_abs: Quantity,
+}
+
+impl AssertEq3Error {
+    /// Returns the precision of epsilon clamped to i8 for compatibility with pretty_dtoa.
+    fn eps_precision(&self) -> i8 {
+        let (_, eps_precision) = get_float_part_lengths(&self.eps.unsafe_value_as_string());
+        // i8 should fit inside usize so this should be safe
+        let eps_precision = eps_precision.min(i8::MAX.try_into().unwrap());
+        let eps_precision: i8 = eps_precision.try_into().unwrap();
+        eps_precision
+    }
+
+    /// Returns the input quantity pretty printed with precision matching epsilon.
+    pub fn fmt_quantity(&self, quantity: &Quantity) -> String {
+        quantity
+            .pretty_print_with_precision(self.eps_precision())
+            .to_string()
+    }
+
+    /// Returns the comparand quantities formatted as strings for pretty error message display.
+    pub fn fmt_comparands(&self) -> (String, String) {
+        let (lhs_converted_len, _) =
+            get_float_part_lengths(&self.lhs_converted.unsafe_value_as_string());
+        let (rhs_converted_len, _) =
+            get_float_part_lengths(&self.rhs_converted.unsafe_value_as_string());
+
+        let width = core::cmp::Ord::max(lhs_converted_len, rhs_converted_len);
+
+        let lhs = self.fmt_comparand(&self.lhs_converted, &self.lhs_original, width);
+        let rhs = self.fmt_comparand(&self.rhs_converted, &self.rhs_original, width);
+
+        (lhs, rhs)
+    }
+
+    fn fmt_comparand(&self, converted: &Quantity, original: &Quantity, width: usize) -> String {
+        let pretty_converted_str = left_pad_integer_part(
+            converted
+                .pretty_print_with_precision(self.eps_precision())
+                .to_string()
+                .as_str(),
+            width,
+        );
+
+        if converted.unit() == original.unit() {
+            pretty_converted_str
+        } else {
+            format!("{} ({})", pretty_converted_str, original)
+        }
+    }
+}
+
+impl Display for AssertEq3Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let (lhs, rhs) = self.fmt_comparands();
+        let diff_abs = self.fmt_quantity(&self.diff_abs);
+        let eps = self.fmt_quantity(&self.eps);
+
+        write!(f, "Assertion failed because the following two quantities differ by {}, which is more than {}:\n  {}\n  {}", diff_abs, eps, lhs, rhs)
+    }
+}
+
+/// Returns the integer (including negative sign if present) and fractional parts of the input number.
+/// The input number should be a float plainly formatted as a string as with `to_string`.
+fn get_float_parts(number: &str) -> (&str, &str) {
+    let parts: Vec<&str> = number.split('.').collect();
+    let integer_part = parts[0];
+    let fractional_part = if parts.len() > 1 { parts[1] } else { "" };
+    (integer_part, fractional_part)
+}
+
+/// Returns the lengths of the integer (including negative sign if present) and fractional parts of the input number.
+/// The input number should be a float plainly formatted as a string as with `to_string`.
+fn get_float_part_lengths(number: &str) -> (usize, usize) {
+    let (integer_part, fractional_part) = get_float_parts(number);
+    (integer_part.len(), fractional_part.len())
+}
+
+/// Returns the input number padded with 0s until the integer part width (number of characters) is exactly integer_part_width
+/// The input number should be a float plainly formatted as a string as with `to_string`.
+fn left_pad_integer_part(number: &str, integer_part_width: usize) -> String {
+    let (integer_part, fractional_part) = get_float_parts(number);
+    let integer_part_len = integer_part.len();
+    let is_negative = integer_part.starts_with('-');
+
+    let integer_part_abs = if is_negative {
+        // Strip negative sign if present
+        &integer_part[1..]
+    } else {
+        integer_part
+    };
+
+    // Calculate padding needed
+    let padding_needed = if integer_part_len >= integer_part_width {
+        0
+    } else {
+        integer_part_width - integer_part_len
+    };
+
+    // Pad integer part with 0s
+    let integer_part_abs_padded = format!(
+        "{:0>width$}",
+        integer_part_abs,
+        width = padding_needed + integer_part_abs.len()
+    );
+
+    // Combine the padded integer part and the fractional part
+    let padded_str = if fractional_part.is_empty() {
+        integer_part_abs_padded
+    } else {
+        format!("{}.{}", integer_part_abs_padded, fractional_part)
+    };
+
+    // Add the negative sign if necessary
+    if is_negative {
+        format!("-{}", padded_str)
+    } else {
+        padded_str
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    mod get_float_parts {
+        use super::*;
+        #[test]
+        fn positive_integer() {
+            let input = 123f64.to_string();
+            let result = get_float_parts(&input);
+            assert_eq!(result, ("123", ""));
+        }
+        #[test]
+        fn negative_integer() {
+            let input = (-123f64).to_string();
+            let result = get_float_parts(&input);
+            assert_eq!(result, ("-123", ""));
+        }
+        #[test]
+        fn integer_with_zero_fractional_part() {
+            let input = (-123.000f64).to_string();
+            let result = get_float_parts(&input);
+            assert_eq!(result, ("-123", ""));
+        }
+
+        #[test]
+        fn positive_number_with_decimal() {
+            let input = (123.456f64).to_string();
+            let result = get_float_parts(&input);
+            assert_eq!(result, ("123", "456"));
+        }
+
+        #[test]
+        fn negative_number_with_decimal() {
+            let input = (-123.456f64).to_string();
+            let result = get_float_parts(&input);
+            assert_eq!(result, ("-123", "456"));
+        }
+    }
+    mod left_pad {
+        use super::*;
+
+        #[test]
+        fn positive_number_with_decimal() {
+            let result = left_pad_integer_part("123.456", 5);
+            assert_eq!(result, "00123.456");
+        }
+
+        #[test]
+        fn negative_number_with_decimal() {
+            let result = left_pad_integer_part("-123.456", 5);
+            assert_eq!(result, "-0123.456");
+        }
+
+        #[test]
+        fn positive_number_without_decimal() {
+            let result = left_pad_integer_part("123", 5);
+            assert_eq!(result, "00123");
+        }
+
+        #[test]
+        fn negative_number_without_decimal() {
+            let result = left_pad_integer_part("-123", 5);
+            assert_eq!(result, "-0123");
+        }
+
+        #[test]
+        fn number_already_wider_than_width() {
+            let result = left_pad_integer_part("12345.6789", 4);
+            assert_eq!(result, "12345.6789");
+        }
+
+        #[test]
+        fn zero_padded() {
+            let result = left_pad_integer_part("0.123", 5);
+            assert_eq!(result, "00000.123");
+        }
+
+        #[test]
+        fn negative_zero_padded() {
+            let result = left_pad_integer_part("-0.123", 5);
+            assert_eq!(result, "-0000.123");
+        }
+    }
+}
diff --git a/numbat/src/interpreter.rs b/numbat/src/interpreter/mod.rs
similarity index 97%
rename from numbat/src/interpreter.rs
rename to numbat/src/interpreter/mod.rs
index 455017c..8ed9fb4 100644
--- a/numbat/src/interpreter.rs
+++ b/numbat/src/interpreter/mod.rs
@@ -1,15 +1,18 @@
+pub(crate) mod assert_eq_3;
+
 use crate::{
     dimension::DimensionRegistry,
     markup::Markup,
     pretty_print::PrettyPrint,
-    quantity::{Quantity, QuantityError},
+    quantity::QuantityError,
     span::Span,
     typed_ast::Statement,
     unit_registry::{UnitRegistry, UnitRegistryError},
 };
 
-use crate::markup as m;
+pub use crate::markup as m;
 
+use assert_eq_3::AssertEq3Error;
 use thiserror::Error;
 
 pub use crate::value::Value;
@@ -30,8 +33,8 @@ pub enum RuntimeError {
     AssertFailed(Span),
     #[error("Assertion failed because the following two values are not the same:\n  {1}\n  {3}")]
     AssertEq2Failed(Span, Value, Span, Value),
-    #[error("Assertion failed because the following two quantities differ by more than {4}:\n  {1}\n  {3}")]
-    AssertEq3Failed(Span, Quantity, Span, Quantity, Quantity),
+    #[error("{0}")]
+    AssertEq3Failed(AssertEq3Error),
     #[error("Could not load exchange rates from European Central Bank.")]
     CouldNotLoadExchangeRates,
     #[error("User error: {0}")]
@@ -162,6 +165,7 @@ pub trait Interpreter {
 #[cfg(test)]
 mod tests {
     use crate::prefix_parser::AcceptsPrefix;
+    use crate::quantity::Quantity;
     use crate::unit::{CanonicalName, Unit};
     use crate::{bytecode_interpreter::BytecodeInterpreter, prefix_transformer::Transformer};
 
diff --git a/numbat/src/number.rs b/numbat/src/number.rs
index 90aa990..d53e828 100644
--- a/numbat/src/number.rs
+++ b/numbat/src/number.rs
@@ -1,4 +1,7 @@
+use std::fmt::Display;
+
 use num_traits::{Pow, ToPrimitive};
+use pretty_dtoa::FmtFloatConfig;
 
 #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] // TODO: we probably want to remove 'Copy' once we move to a more sophisticated numerical type
 pub struct Number(pub f64);
@@ -19,11 +22,23 @@ impl Number {
         Number::from_f64(self.to_f64().pow(other.to_f64()))
     }
 
+    pub fn abs(self) -> Self {
+        Number::from_f64(self.to_f64().abs())
+    }
+
     fn is_integer(self) -> bool {
         self.0.trunc() == self.0
     }
 
+    /// Pretty prints with default options
     pub fn pretty_print(self) -> String {
+        self.pretty_print_with_options(None)
+    }
+
+    /// Pretty prints with the given options if options is not None.
+    /// If options is None, default options will be used.
+    /// If options is not None, float-based format handling is used and integer-based format handling is skipped.
+    pub fn pretty_print_with_options(self, options: Option<FmtFloatConfig>) -> String {
         let number = self.0;
 
         // 64-bit floats can accurately represent integers up to 2^52 [1],
@@ -31,7 +46,8 @@ impl Number {
         //
         // [1] https://stackoverflow.com/a/43656339
         //
-        if self.is_integer() && self.0.abs() < 1e15 {
+        // Skip special format handling for integers if options is not None.
+        if options.is_none() && self.is_integer() && self.0.abs() < 1e15 {
             use num_format::{CustomFormat, Grouping, ToFormattedString};
 
             let format = CustomFormat::builder()
@@ -52,17 +68,26 @@ impl Number {
         } else {
             use pretty_dtoa::{dtoa, FmtFloatConfig};
 
-            let config = FmtFloatConfig::default()
-                .max_significant_digits(6)
-                .add_point_zero(false)
-                .lower_e_break(-6)
-                .upper_e_break(6)
-                .round();
+            let config = if let Some(options) = options {
+                options
+            } else {
+                FmtFloatConfig::default()
+                    .max_significant_digits(6)
+                    .add_point_zero(false)
+                    .lower_e_break(-6)
+                    .upper_e_break(6)
+                    .round()
+            };
 
             let formatted_number = dtoa(number, config);
 
             if formatted_number.contains('.') && !formatted_number.contains('e') {
-                let formatted_number = formatted_number.trim_end_matches('0');
+                let formatted_number = if config.max_sig_digits.is_some() {
+                    formatted_number.trim_end_matches('0')
+                } else {
+                    &formatted_number
+                };
+
                 if formatted_number.ends_with('.') {
                     format!("{formatted_number}0")
                 } else {
@@ -77,6 +102,12 @@ impl Number {
     }
 }
 
+impl Display for Number {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.to_f64().fmt(f)
+    }
+}
+
 impl std::ops::Add for Number {
     type Output = Number;
 
@@ -158,3 +189,10 @@ fn test_pretty_print() {
     assert_eq!(Number::from_f64(0.000001).pretty_print(), "0.000001");
     assert_eq!(Number::from_f64(0.0000001).pretty_print(), "1.0e-7");
 }
+
+#[test]
+fn test_abs() {
+    assert_eq!(Number::from_f64(0.0).abs(), Number::from_f64(0.0));
+    assert_eq!(Number::from_f64(1.0).abs(), Number::from_f64(1.0));
+    assert_eq!(Number::from_f64(-1.0).abs(), Number::from_f64(1.0));
+}
diff --git a/numbat/src/pretty_print.rs b/numbat/src/pretty_print.rs
index 18e07c1..3cf6eb2 100644
--- a/numbat/src/pretty_print.rs
+++ b/numbat/src/pretty_print.rs
@@ -1,6 +1,7 @@
 use crate::markup::Markup;
 
 pub trait PrettyPrint {
+    /// Pretty prints with default options.
     fn pretty_print(&self) -> Markup;
 }
 
diff --git a/numbat/src/quantity.rs b/numbat/src/quantity.rs
index 2a8c356..2cedfc5 100644
--- a/numbat/src/quantity.rs
+++ b/numbat/src/quantity.rs
@@ -6,6 +6,7 @@ use crate::unit::{is_multiple_of, Unit, UnitFactor};
 use itertools::Itertools;
 use num_rational::Ratio;
 use num_traits::{FromPrimitive, Zero};
+use pretty_dtoa::FmtFloatConfig;
 use thiserror::Error;
 
 #[derive(Clone, Debug, Error, PartialEq, Eq)]
@@ -53,6 +54,10 @@ impl Quantity {
         self.value.to_f64() == 0.0
     }
 
+    pub fn abs(self) -> Self {
+        Quantity::new(self.value.abs(), self.unit)
+    }
+
     pub fn to_base_unit_representation(&self) -> Quantity {
         let (unit, factor) = self.unit.to_base_unit_representation();
         Quantity::new(self.value * factor, unit)
@@ -330,9 +335,17 @@ impl PartialOrd for Quantity {
 
 impl PrettyPrint for Quantity {
     fn pretty_print(&self) -> crate::markup::Markup {
+        self.pretty_print_with_options(None)
+    }
+}
+
+impl Quantity {
+    /// Pretty prints with the given options.
+    /// If options is None, default options will be used.
+    fn pretty_print_with_options(&self, options: Option<FmtFloatConfig>) -> crate::markup::Markup {
         use crate::markup;
 
-        let formatted_number = self.unsafe_value().pretty_print();
+        let formatted_number = self.unsafe_value().pretty_print_with_options(options);
 
         let unit_str = format!("{}", self.unit());
 
@@ -344,6 +357,22 @@ impl PrettyPrint for Quantity {
             }
             + markup::unit(unit_str)
     }
+
+    /// Pretty prints with the given precision. Disables e (scientific) notation.
+    /// Prints without fractional part if precision is 0.
+    pub fn pretty_print_with_precision(&self, precision: i8) -> crate::markup::Markup {
+        let options = FmtFloatConfig::default()
+            .min_decimal_digits(precision)
+            .max_decimal_digits(precision)
+            .add_point_zero(false)
+            .force_no_e_notation()
+            .round();
+        self.pretty_print_with_options(Some(options))
+    }
+
+    pub fn unsafe_value_as_string(&self) -> String {
+        self.unsafe_value().to_string()
+    }
 }
 
 impl std::fmt::Display for Quantity {
@@ -445,6 +474,24 @@ mod tests {
         }
     }
 
+    #[test]
+    fn abs() {
+        assert_eq!(
+            Quantity::new_f64(0.0, Unit::scalar()).abs(),
+            Quantity::new_f64(0.0, Unit::scalar())
+        );
+
+        assert_eq!(
+            Quantity::new_f64(1.0, Unit::scalar()).abs(),
+            Quantity::new_f64(1.0, Unit::scalar())
+        );
+
+        assert_eq!(
+            Quantity::new_f64(-1.0, Unit::scalar()).abs(),
+            Quantity::new_f64(1.0, Unit::scalar())
+        );
+    }
+
     #[test]
     fn full_simplify_basic() {
         let q = Quantity::new_f64(2.0, Unit::meter() / Unit::second());


Test patch:
diff --git a/numbat/tests/interpreter.rs b/numbat/tests/interpreter.rs
index 1447ed4..e67ddce 100644
--- a/numbat/tests/interpreter.rs
+++ b/numbat/tests/interpreter.rs
@@ -22,6 +22,23 @@ fn expect_output_with_context(ctx: &mut Context, code: &str, expected_output: im
     }
 }
 
+#[track_caller]
+fn succeed(code: &str) -> String {
+    let mut ctx = get_test_context();
+    let ret = ctx.interpret(code, CodeSource::Internal);
+    match ret {
+        Err(e) => panic!("was supposed to succeed but instead got:\n{}", e),
+        Ok((_stmts, ret)) => {
+            if let InterpreterResult::Value(val) = ret {
+                let fmt = PlainTextFormatter {};
+                fmt.format(&val.pretty_print(), false)
+            } else {
+                String::new()
+            }
+        }
+    }
+}
+
 #[track_caller]
 fn fail(code: &str) -> NumbatError {
     let mut ctx = get_test_context();
@@ -816,3 +833,104 @@ fn test_statement_pretty_printing() {
 
     expect_pretty_print("fn f<Z>(z: Z) = z", "fn f<Z>(z: Z) -> Z = z");
 }
+#[cfg(test)]
+mod tests {
+    use super::*;
+    #[cfg(test)]
+    mod assert_eq_3 {
+        use super::*;
+
+        #[test]
+        fn succeeds_when_eps_larger_than_diff() {
+            insta::assert_snapshot!(succeed("assert_eq(200cm, 201cm, 2cm)"), @"");
+        }
+
+        #[test]
+        fn succeeds_when_eps_is_equal_to_diff() {
+            insta::assert_snapshot!(succeed("assert_eq(200cm, 201cm, 1cm)"), @"");
+        }
+
+        #[test]
+        fn fails_when_types_mismatch() {
+            insta::assert_snapshot!(fail("assert_eq(200cm, 201cm, 2 watts)"), @"Argument types in assert_eq calls must match");
+        }
+
+        #[test]
+        fn fails_and_does_not_show_original_values_when_same_units() {
+            insta::assert_snapshot!(fail("assert_eq(200cm, 201cm, 0.1cm)"), @r###"
+            Assertion failed because the following two quantities differ by 1.0 cm, which is more than 0.1 cm:
+              200.0 cm
+              201.0 cm
+            "###);
+        }
+
+        #[test]
+        fn fails_and_show_original_values_for_rhs_only() {
+            // approximate equality failure with different units for rhs shows original values in parentheses for rhs only
+            insta::assert_snapshot!(fail("assert_eq(2000mm, 201cm, 0.1cm to mm)"), @r###"
+            Assertion failed because the following two quantities differ by 10 mm, which is more than 1 mm:
+              2000 mm
+              2010 mm (201 cm)
+            "###);
+        }
+
+        #[test]
+        fn fails_and_show_original_values_for_lhs_only() {
+            // approximate equality failure with different units for lhs shows original values in parentheses for lhs only
+            insta::assert_snapshot!(fail("assert_eq(2m, 2010mm, 0.1cm to mm)"), @r###"
+            Assertion failed because the following two quantities differ by 10 mm, which is more than 1 mm:
+              2000 mm (2 m)
+              2010 mm
+            "###);
+        }
+
+        #[test]
+        fn fails_and_show_original_values_for_lhs_and_rhs() {
+            // approximate equality failure with different units for lhs and rhs shows original values in parentheses for lhs and rhs
+            insta::assert_snapshot!(fail("assert_eq(2m, 201cm, 0.1cm to mm)"), @r###"
+            Assertion failed because the following two quantities differ by 10 mm, which is more than 1 mm:
+              2000 mm (2 m)
+              2010 mm (201 cm)
+            "###);
+        }
+
+        #[test]
+        fn fails_and_formats_to_match_precision_of_eps() {
+            // check lhs, rhs, and diff have the same number of decimal places as epsilon
+            // extra 0s at the end of epsilon are not currently considered
+            insta::assert_snapshot!(fail("assert_eq(212121000.123456cm, 212121001.123456cm, 0.900cm)"), @r###"
+            Assertion failed because the following two quantities differ by 1.0 cm, which is more than 0.9 cm:
+              212121000.1 cm
+              212121001.1 cm
+            "###);
+
+            // check left padding and alignment when lhs and rhs are positive
+            insta::assert_snapshot!(fail("assert_eq(2.0000006cm, 243.311336cm, 0.0001mm)"), @r###"
+            Assertion failed because the following two quantities differ by 2413.1134 mm, which is more than 0.0001 mm:
+              0020.0000 mm (2.0 cm)
+              2433.1134 mm (243.311 cm)
+            "###);
+
+            // check left padding and alignment when lhs and rhs are negative
+            insta::assert_snapshot!(fail("assert_eq(-2.0000006cm, -243cm, 0.0001mm)"), @r###"
+            Assertion failed because the following two quantities differ by 2410.0000 mm, which is more than 0.0001 mm:
+              -0020.0000 mm (-2.0 cm)
+              -2430.0000 mm (-243 cm)
+            "###);
+
+            // check left padding and alignment when only rhs is negative
+            insta::assert_snapshot!(fail("assert_eq(2.0000006cm, -243cm, 0.0001mm)"), @r###"
+            Assertion failed because the following two quantities differ by 2450.0000 mm, which is more than 0.0001 mm:
+              00020.0000 mm (2.0 cm)
+              -2430.0000 mm (-243 cm)
+            "###);
+
+            // check lhs, rhs, and diff have the same number of decimal places as epsilon when epsilon is negative
+            insta::assert_snapshot!(fail("assert_eq(212121000.123456cm, 212121001.123456cm, -0.900cm)"), @r###"
+            Assertion failed because the following two quantities differ by 1.0 cm, which is more than -0.9 cm:
+              212121000.1 cm
+              212121001.1 cm
+            "###);
+        }
+    }
+}