feat(CORE-001A): add decimal unit value foundation

This commit is contained in:
Bu5hm4nn
2026-03-24 21:33:17 +01:00
parent 5ac66ea97b
commit a69fdf6762
5 changed files with 1153 additions and 9 deletions

227
tests/test_units.py Normal file
View 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]