feat(CORE-001A): add decimal unit value foundation
This commit is contained in:
227
tests/test_units.py
Normal file
227
tests/test_units.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.units import (
|
||||
BaseCurrency,
|
||||
Money,
|
||||
PricePerWeight,
|
||||
Weight,
|
||||
WeightUnit,
|
||||
decimal_from_float,
|
||||
to_decimal,
|
||||
)
|
||||
|
||||
|
||||
def test_to_decimal_accepts_decimal_int_and_string() -> None:
|
||||
assert to_decimal(Decimal("1.25")) == Decimal("1.25")
|
||||
assert to_decimal(2) == Decimal("2")
|
||||
assert to_decimal("3.50") == Decimal("3.50")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [1.25, True, False, object()])
|
||||
def test_to_decimal_rejects_unsafe_or_unknown_types(value: object) -> None:
|
||||
with pytest.raises(TypeError):
|
||||
to_decimal(value) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_decimal_from_float_converts_explicitly() -> None:
|
||||
assert decimal_from_float(31.1) == Decimal("31.1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")])
|
||||
def test_decimal_from_float_rejects_non_finite_values(value: float) -> None:
|
||||
with pytest.raises(ValueError, match="must be finite"):
|
||||
decimal_from_float(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["NaN", "Infinity", "-Infinity"])
|
||||
def test_to_decimal_rejects_non_finite_values(value: str) -> None:
|
||||
with pytest.raises(ValueError, match="must be finite"):
|
||||
to_decimal(value)
|
||||
|
||||
|
||||
def test_money_coerces_currency_from_string() -> None:
|
||||
money = Money(amount=Decimal("10"), currency="USD")
|
||||
|
||||
assert money.currency is BaseCurrency.USD
|
||||
|
||||
|
||||
def test_weight_coerces_unit_from_string() -> None:
|
||||
weight = Weight(amount=Decimal("1"), unit="ozt")
|
||||
|
||||
assert weight.unit is WeightUnit.OUNCE_TROY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory", "message"),
|
||||
[
|
||||
(lambda: Money(amount=Decimal("1"), currency="BAD"), "Invalid currency"),
|
||||
(lambda: Weight(amount=Decimal("1"), unit="bad"), "Invalid weight unit"),
|
||||
(
|
||||
lambda: PricePerWeight(amount=Decimal("1"), currency="USD", per_unit="bad"),
|
||||
"Invalid weight unit",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_value_objects_reject_invalid_enum_values(factory: object, message: str) -> None:
|
||||
with pytest.raises(ValueError, match=message):
|
||||
factory() # type: ignore[operator]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("weight", "target_unit", "expected_amount"),
|
||||
[
|
||||
(Weight(amount=Decimal("1"), unit=WeightUnit.KILOGRAM), WeightUnit.GRAM, Decimal("1000")),
|
||||
(
|
||||
Weight(amount=Decimal("1"), unit=WeightUnit.OUNCE_TROY),
|
||||
WeightUnit.GRAM,
|
||||
Decimal("31.1034768"),
|
||||
),
|
||||
(
|
||||
Weight(amount=Decimal("31.1034768"), unit=WeightUnit.GRAM),
|
||||
WeightUnit.OUNCE_TROY,
|
||||
Decimal("1"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_weight_to_unit_converts_explicitly(weight: Weight, target_unit: WeightUnit, expected_amount: Decimal) -> None:
|
||||
converted = weight.to_unit(target_unit)
|
||||
|
||||
assert converted.unit is target_unit
|
||||
assert converted.amount == expected_amount
|
||||
|
||||
|
||||
def test_weight_addition_converts_other_weight_to_left_hand_unit() -> None:
|
||||
left = Weight(amount=Decimal("1"), unit=WeightUnit.KILOGRAM)
|
||||
right = Weight(amount=Decimal("500"), unit=WeightUnit.GRAM)
|
||||
|
||||
result = left + right
|
||||
|
||||
assert result.unit is WeightUnit.KILOGRAM
|
||||
assert result.amount == Decimal("1.5")
|
||||
|
||||
|
||||
def test_money_addition_requires_same_currency() -> None:
|
||||
total = Money(amount=Decimal("10"), currency=BaseCurrency.USD) + Money(
|
||||
amount=Decimal("2.50"), currency=BaseCurrency.USD
|
||||
)
|
||||
|
||||
assert total == Money(amount=Decimal("12.50"), currency=BaseCurrency.USD)
|
||||
|
||||
|
||||
def test_money_addition_rejects_currency_mismatch() -> None:
|
||||
with pytest.raises(ValueError, match="Currency mismatch"):
|
||||
_ = Money(amount=Decimal("10"), currency=BaseCurrency.USD) + Money(
|
||||
amount=Decimal("5"), currency=BaseCurrency.EUR
|
||||
)
|
||||
|
||||
|
||||
def test_price_per_weight_to_unit_converts_explicitly() -> None:
|
||||
price = PricePerWeight(
|
||||
amount=Decimal("4400"),
|
||||
currency=BaseCurrency.USD,
|
||||
per_unit=WeightUnit.OUNCE_TROY,
|
||||
)
|
||||
|
||||
converted = price.to_unit(WeightUnit.GRAM)
|
||||
|
||||
assert converted.currency is BaseCurrency.USD
|
||||
assert converted.per_unit is WeightUnit.GRAM
|
||||
assert converted.amount == Decimal("141.4632849019631142972415225")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("weight", "price", "expected"),
|
||||
[
|
||||
(
|
||||
Weight(amount=Decimal("220"), unit=WeightUnit.OUNCE_TROY),
|
||||
PricePerWeight(
|
||||
amount=Decimal("4400"),
|
||||
currency=BaseCurrency.USD,
|
||||
per_unit=WeightUnit.OUNCE_TROY,
|
||||
),
|
||||
Money(amount=Decimal("968000"), currency=BaseCurrency.USD),
|
||||
),
|
||||
(
|
||||
Weight(amount=Decimal("31.1034768"), unit=WeightUnit.GRAM),
|
||||
PricePerWeight(
|
||||
amount=Decimal("4400"),
|
||||
currency=BaseCurrency.USD,
|
||||
per_unit=WeightUnit.OUNCE_TROY,
|
||||
),
|
||||
Money(amount=Decimal("4400"), currency=BaseCurrency.USD),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_weight_multiplied_by_price_per_weight_returns_money(
|
||||
weight: Weight, price: PricePerWeight, expected: Money
|
||||
) -> None:
|
||||
assert weight * price == expected
|
||||
assert price * weight == expected
|
||||
|
||||
|
||||
def test_unsupported_operator_pair_fails_closed() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
_ = Money(amount=Decimal("10"), currency=BaseCurrency.USD) * Money(
|
||||
amount=Decimal("2"), currency=BaseCurrency.USD
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
lambda: Money(amount=Decimal("10"), currency=BaseCurrency.USD) * True,
|
||||
lambda: Money(amount=Decimal("10"), currency=BaseCurrency.USD) / True,
|
||||
lambda: Weight(amount=Decimal("10"), unit=WeightUnit.GRAM) * True,
|
||||
lambda: Weight(amount=Decimal("10"), unit=WeightUnit.GRAM) / True,
|
||||
lambda: PricePerWeight(amount=Decimal("10"), currency=BaseCurrency.USD, per_unit=WeightUnit.GRAM) * True,
|
||||
],
|
||||
)
|
||||
def test_scalar_operators_reject_boolean_values(expression: object) -> None:
|
||||
with pytest.raises(TypeError):
|
||||
expression() # type: ignore[operator]
|
||||
|
||||
|
||||
def test_price_per_weight_rejects_negative_amount() -> None:
|
||||
with pytest.raises(ValueError, match="must be non-negative"):
|
||||
PricePerWeight(
|
||||
amount=Decimal("-1"),
|
||||
currency=BaseCurrency.USD,
|
||||
per_unit=WeightUnit.GRAM,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory", "message"),
|
||||
[
|
||||
(lambda: Money(amount="NaN", currency=BaseCurrency.USD), "must be finite"),
|
||||
(lambda: Weight(amount="Infinity", unit=WeightUnit.GRAM), "must be finite"),
|
||||
(
|
||||
lambda: PricePerWeight(
|
||||
amount="-Infinity",
|
||||
currency=BaseCurrency.USD,
|
||||
per_unit=WeightUnit.GRAM,
|
||||
),
|
||||
"must be finite",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_value_objects_reject_non_finite_amounts(factory: object, message: str) -> None:
|
||||
with pytest.raises(ValueError, match=message):
|
||||
factory() # type: ignore[operator]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"factory",
|
||||
[
|
||||
lambda: Money(amount=True, currency=BaseCurrency.USD),
|
||||
lambda: Weight(amount=True, unit=WeightUnit.GRAM),
|
||||
lambda: PricePerWeight(amount=True, currency=BaseCurrency.USD, per_unit=WeightUnit.GRAM),
|
||||
],
|
||||
)
|
||||
def test_value_objects_reject_boolean_amounts(factory: object) -> None:
|
||||
with pytest.raises(TypeError, match="Boolean values are not valid Decimal inputs"):
|
||||
factory() # type: ignore[operator]
|
||||
Reference in New Issue
Block a user