diff --git a/NEWS b/NEWS index 2bf0e4f7..0d86858b 100644 --- a/NEWS +++ b/NEWS @@ -6,6 +6,11 @@ Changes and improvements to testtools_, grouped by release. 2.8.1 ~~~~~ +Improvements +------------ + +* Add ``Nearly`` matcher for approximate numerical comparisons. (#222) + Changes ------- diff --git a/testtools/matchers/__init__.py b/testtools/matchers/__init__.py index 6f9a341d..1a4fa55e 100644 --- a/testtools/matchers/__init__.py +++ b/testtools/matchers/__init__.py @@ -47,6 +47,7 @@ "MatchesRegex", "MatchesSetwise", "MatchesStructure", + "Nearly", "Never", "Not", "NotEquals", @@ -71,6 +72,7 @@ IsInstance, LessThan, MatchesRegex, + Nearly, NotEquals, SameMembers, StartsWith, diff --git a/testtools/matchers/_basic.py b/testtools/matchers/_basic.py index b3451a53..19904a13 100644 --- a/testtools/matchers/_basic.py +++ b/testtools/matchers/_basic.py @@ -10,6 +10,7 @@ "IsInstance", "LessThan", "MatchesRegex", + "Nearly", "NotEquals", "SameMembers", "StartsWith", @@ -148,6 +149,65 @@ class GreaterThan(_BinaryComparison): mismatch_string = "<=" +class _NotNearlyEqual(Mismatch): + """Mismatch for Nearly matcher.""" + + def __init__(self, actual, expected, delta): + self.actual = actual + self.expected = expected + self.delta = delta + + def describe(self): + try: + diff = abs(self.actual - self.expected) + return ( + f"{self.actual!r} is not nearly equal to {self.expected!r}: " + f"difference {diff!r} exceeds tolerance {self.delta!r}" + ) + except (TypeError, AttributeError): + return ( + f"{self.actual!r} is not nearly equal to {self.expected!r} " + f"within {self.delta!r}" + ) + + +class Nearly(Matcher): + """Matches if a value is nearly equal to the expected value. + + This matcher is useful for comparing floating point values where exact + equality cannot be relied upon due to precision limitations. + + The matcher checks if the absolute difference between the actual and + expected values is less than or equal to a specified tolerance (delta). + + This works for any type that supports subtraction and absolute value + operations (e.g., integers, floats, Decimal, etc.). + """ + + def __init__(self, expected, delta=0.001): + """Create a Nearly matcher. + + :param expected: The expected value to compare against. + :param delta: The maximum allowed absolute difference (tolerance). + Default is 0.001. + """ + self.expected = expected + self.delta = delta + + def __str__(self): + return f"Nearly({self.expected!r}, delta={self.delta!r})" + + def match(self, actual): + try: + diff = abs(actual - self.expected) + if diff <= self.delta: + return None + except (TypeError, AttributeError): + # Can't compute difference - definitely not nearly equal + pass + return _NotNearlyEqual(actual, self.expected, self.delta) + + class SameMembers(Matcher): """Matches if two iterators have the same members. diff --git a/testtools/tests/matchers/test_basic.py b/testtools/tests/matchers/test_basic.py index 4a08ff8e..f7bd3da3 100644 --- a/testtools/tests/matchers/test_basic.py +++ b/testtools/tests/matchers/test_basic.py @@ -20,10 +20,12 @@ IsInstance, LessThan, MatchesRegex, + Nearly, NotEquals, SameMembers, StartsWith, _BinaryMismatch, + _NotNearlyEqual, ) from testtools.tests.helpers import FullStackRunTest from testtools.tests.matchers.helpers import TestMatchersInterface @@ -443,6 +445,115 @@ class TestHasLength(TestCase, TestMatchersInterface): ] +class TestNearlyInterface(TestCase, TestMatchersInterface): + matches_matcher: ClassVar = Nearly(4.0, delta=0.5) + matches_matches: ClassVar = [4.0, 4.5, 3.5, 4.25, 3.75] + matches_mismatches: ClassVar = [4.51, 3.49, 5.0, 2.0, "not a number"] + + str_examples: ClassVar = [ + ("Nearly(4.0, delta=0.5)", Nearly(4.0, delta=0.5)), + ("Nearly(1.5, delta=0.001)", Nearly(1.5, delta=0.001)), + ("Nearly(10, delta=1)", Nearly(10, delta=1)), + ] + + describe_examples: ClassVar = [ + ( + "5.0 is not nearly equal to 4.0: difference 1.0 exceeds tolerance 0.5", + 5.0, + Nearly(4.0, delta=0.5), + ), + ( + "3.0 is not nearly equal to 4.0: difference 1.0 exceeds tolerance 0.5", + 3.0, + Nearly(4.0, delta=0.5), + ), + ] + + +class TestNearlyMismatch(TestCase): + """Tests for the _NotNearlyEqual mismatch class.""" + + def test_describe_with_numeric_values(self): + """Test describe() with valid numeric values.""" + mismatch = _NotNearlyEqual(5.0, 4.0, 0.5) + self.assertEqual( + "5.0 is not nearly equal to 4.0: difference 1.0 exceeds tolerance 0.5", + mismatch.describe(), + ) + + def test_describe_with_non_numeric_values(self): + """Test describe() when subtraction is not supported.""" + mismatch = _NotNearlyEqual("string", 4.0, 0.5) + self.assertEqual( + "'string' is not nearly equal to 4.0 within 0.5", mismatch.describe() + ) + + +class TestNearlyBehavior(TestCase): + """Additional tests for Nearly matcher behavior.""" + + def test_integers_match(self): + """Test that Nearly works with integers.""" + matcher = Nearly(10, delta=2) + self.assertIsNone(matcher.match(11)) + self.assertIsNone(matcher.match(9)) + self.assertIsNone(matcher.match(10)) + + def test_integers_mismatch(self): + """Test that Nearly correctly fails with integers.""" + matcher = Nearly(10, delta=2) + mismatch = matcher.match(13) + self.assertIsNotNone(mismatch) + self.assertIn("13", mismatch.describe()) + + def test_exact_boundary_matches(self): + """Test that values at exactly the boundary match.""" + matcher = Nearly(1.0, delta=0.25) + # Exactly at the boundary should match (<=) + self.assertIsNone(matcher.match(1.25)) + self.assertIsNone(matcher.match(0.75)) + + def test_just_outside_boundary_mismatches(self): + """Test that values just outside the boundary don't match.""" + matcher = Nearly(1.0, delta=0.1) + # Just outside the boundary should not match + self.assertIsNotNone(matcher.match(1.10001)) + self.assertIsNotNone(matcher.match(0.89999)) + + def test_negative_numbers(self): + """Test that Nearly works with negative numbers.""" + matcher = Nearly(-5.0, delta=1.0) + self.assertIsNone(matcher.match(-4.5)) + self.assertIsNone(matcher.match(-5.5)) + self.assertIsNotNone(matcher.match(-3.5)) + + def test_zero_delta(self): + """Test Nearly with zero delta (exact match required).""" + matcher = Nearly(1.0, delta=0.0) + self.assertIsNone(matcher.match(1.0)) + self.assertIsNotNone(matcher.match(1.0001)) + + def test_default_delta(self): + """Test that default delta is 0.001.""" + matcher = Nearly(1.0) + self.assertEqual(0.001, matcher.delta) + self.assertIsNone(matcher.match(1.0005)) + self.assertIsNotNone(matcher.match(1.002)) + + def test_non_numeric_type_mismatch(self): + """Test that non-numeric types result in a mismatch.""" + matcher = Nearly(1.0, delta=0.1) + mismatch = matcher.match("string") + self.assertIsNotNone(mismatch) + self.assertIn("string", mismatch.describe()) + + def test_none_type_mismatch(self): + """Test that None results in a mismatch.""" + matcher = Nearly(1.0, delta=0.1) + mismatch = matcher.match(None) + self.assertIsNotNone(mismatch) + + def test_suite(): from unittest import TestLoader