diff --git a/tests/common/general_maths/operator_inversion_pairing.py b/tests/common/general_maths/operator_inversion_pairing.py new file mode 100644 index 00000000000..c501978bea6 --- /dev/null +++ b/tests/common/general_maths/operator_inversion_pairing.py @@ -0,0 +1,49 @@ +from collections.abc import Callable +from dataclasses import dataclass + +import pytest + + +@dataclass(frozen=True) +class OperatorInversionPairing: + """Represents a pair of mutually reciprocal maths functions, with unique mappings of input to output. + Useful for sanity check behavioural tests above the unit level but below integration level. + A typical use case is to ensure that after two mutually cancelling numerical operations, + an original input number is restored. This helps tests flag whenever one function breaks, + for example - owing to a typo or incorrect or inverted multiplication factor. + ( Yes there are even numbers of self-cancelling mistakes which can still hide bugs but + hopefully the individual functions, functional tests flush those out ). + + Attributes: + unary_op: A unary operation mathematical function (specifically with unique one-to-one mapping). + inverse_op: The inverse unary operation. + """ + + unary_op: Callable[[float], float] + inverse_op: Callable[[float], float] + + def _composed_operator(self, x: float) -> float: + """Applies both the unary operation followed by the inverse operation on a numerical input. + On, for example, the happy path of a test, this round-trip can be expected to result in the original value x. + Internal method. + + Args: + x (float): Any numerical argument suitable for the unary operations under test. + + Returns: + float: The result from nested application of first the unary operation and then its inverse on x. + """ + _f_of_x = self.unary_op(x) + return self.inverse_op(_f_of_x) + + def composed_operator_is_consistent_with_identity_operator( + self, probe_x: float + ) -> bool: + """Used in tests when verifying that a pair of functions compose to act like the identity operator. + Namely that for f, g where g is the inverse of f, asserts that g(f(x)) is consistent with x to good approximation. + + Args: + probe_x (float): Any numerical argument suitable for the unary operations under test. + """ + _round_trip_net_effect = self._composed_operator(probe_x) + return _round_trip_net_effect == pytest.approx(probe_x) diff --git a/tests/common/general_maths/test_arithmetic_conversions.py b/tests/common/general_maths/test_arithmetic_conversions.py index dafbea12af9..d9ebaba1e95 100644 --- a/tests/common/general_maths/test_arithmetic_conversions.py +++ b/tests/common/general_maths/test_arithmetic_conversions.py @@ -1,4 +1,5 @@ import math +from collections.abc import Callable import pydantic import pytest @@ -14,6 +15,8 @@ convert_percentage_to_factor, ) +from .operator_inversion_pairing import OperatorInversionPairing + # expected success tests (the 'Happy Path'): All numbers here are arbitrary @pytest.mark.parametrize("input,result", [(1.0, 0.1), (100.0, 10.0)]) @@ -56,29 +59,51 @@ def test_conversion_from_microns_to_centimetres(input, result): assert convert_microns_to_cm(input) == pytest.approx(result) -# Circular tests (all numbers here arbitrary) - - -@pytest.mark.parametrize("input", [0.0, 1.0, 10.0, 100.0]) -def test_circular_cm_to_mm_and_back(input): - assert convert_cm_to_mm(convert_mm_to_cm(input)) == pytest.approx(input) - assert convert_mm_to_cm(convert_cm_to_mm(input)) == pytest.approx(input) - +# Circular "sanity check" tests, exercise pairs of reciprocating functions +# proving the result of applying a function and its inverse results in the original value -@pytest.mark.parametrize("input", [0.0, 1.0, 10.0, 100.0]) -def test_circular_microns_to_mm_and_back(input): - assert convert_microns_to_mm(convert_mm_to_microns(input)) == pytest.approx(input) - assert convert_mm_to_microns(convert_microns_to_mm(input)) == pytest.approx(input) - -@pytest.mark.parametrize("input", [0.0, 1.0, 10.0, 100.0]) -def test_circular_percentage_to_factor_and_back(input): - assert convert_percentage_to_factor( - convert_factor_to_percentage(input) - ) == pytest.approx(input) - assert convert_factor_to_percentage( - convert_percentage_to_factor(input) - ) == pytest.approx(input) +@pytest.mark.parametrize( + "f, g, numerical_args", + [ + ( + convert_ev_to_kev, + lambda k: k * 1000.0, + [16.83, 0.0, 0.037, 1.0, 6.208, 18, 12345.6, 28906.4], + ), + ( + convert_mm_to_cm, + convert_cm_to_mm, + [-16.83, 0.0, 0.037, 1.0, 6.208, 18, 102.99], + ), + ( + convert_microns_to_cm, + lambda x: convert_mm_to_microns(convert_cm_to_mm(x)), + [-6.119, 0.0, 0.764, 1.02, 62.45, 12754, 3154.59], + ), + ( + convert_microns_to_mm, + convert_mm_to_microns, + [-12.38, 0.0, 0.307, 1.0, 6.45, 24, 231.089], + ), + ( + convert_factor_to_percentage, + convert_percentage_to_factor, + [0.0, 1.0, 0.5, 0.367, 27.404, 100.0, 99.8, 53.647], + ), + ], +) +def test_reciprocal_function_pairs_nest_consistent_with_identity( + f: Callable[[float], float], + g: Callable[[float], float], + numerical_args: list[float], +): + for op_pair in [ + OperatorInversionPairing(f, g), + OperatorInversionPairing(g, f), + ]: + for x in numerical_args: + assert op_pair.composed_operator_is_consistent_with_identity_operator(x) # The inauspicuous path