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]