Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions tests/common/general_maths/operator_inversion_pairing.py
Original file line number Diff line number Diff line change
@@ -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)
67 changes: 46 additions & 21 deletions tests/common/general_maths/test_arithmetic_conversions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import math
from collections.abc import Callable

import pydantic
import pytest
Expand All @@ -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)])
Expand Down Expand Up @@ -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
Expand Down
Loading