diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ee7fc..f687a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## Unreleased + +### Bug fixes + +* Fix infinite loop in `Decimal.to_integer/1` when the coefficient is + zero and the exponent is negative (e.g. `Decimal.new("0.0")`). Such + values now correctly convert to the integer `0`. + ## v3.0.0 (2026-05-07) ### Note on the new defaults diff --git a/lib/decimal.ex b/lib/decimal.ex index 6d706e4..fb4024c 100644 --- a/lib/decimal.ex +++ b/lib/decimal.ex @@ -2319,6 +2319,8 @@ defmodule Decimal do defp do_normalize(coef, exp), do: do_normalize_one(coef, exp) + defp do_normalize_one(0, _exp), do: %Decimal{coef: 0, exp: 0} + defp do_normalize_one(coef, exp) when Kernel.rem(coef, 10) == 0 do do_normalize_one(Kernel.div(coef, 10), exp + 1) end @@ -2337,6 +2339,8 @@ defmodule Decimal do defp strip_trailing_zeros(coef, exp), do: strip_trailing_zeros_one(coef, exp) + defp strip_trailing_zeros_one(0, _exp), do: {0, 0} + defp strip_trailing_zeros_one(coef, exp) when Kernel.rem(coef, 10) == 0 do strip_trailing_zeros_one(Kernel.div(coef, 10), exp + 1) end diff --git a/test/decimal/property_test.exs b/test/decimal/property_test.exs index 815f3aa..dea15ec 100644 --- a/test/decimal/property_test.exs +++ b/test/decimal/property_test.exs @@ -2,6 +2,8 @@ defmodule Decimal.PropertyTest do use ExUnit.Case, async: true use ExUnitProperties + import DecimalGenerators + describe "compare/2" do test "integer equality" do check all( @@ -31,6 +33,187 @@ defmodule Decimal.PropertyTest do end end + describe "algebraic identities" do + property "add/2 is commutative" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + assert Decimal.compare(Decimal.add(a, b), Decimal.add(b, a)) == :eq + end + end + + property "add/2 with zero is identity" do + zero = Decimal.new(0) + + check all(a <- decimal(), max_runs: 100) do + assert Decimal.compare(Decimal.add(a, zero), a) == :eq + end + end + + property "add/2 of a and negate(a) is zero" do + zero = Decimal.new(0) + + check all(a <- decimal(), max_runs: 100) do + assert Decimal.compare(Decimal.add(a, Decimal.negate(a)), zero) == :eq + end + end + + property "sub/2 equals add(a, negate(b))" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + assert Decimal.compare(Decimal.sub(a, b), Decimal.add(a, Decimal.negate(b))) == :eq + end + end + + property "mult/2 is commutative" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + assert Decimal.compare(Decimal.mult(a, b), Decimal.mult(b, a)) == :eq + end + end + + property "mult/2 with one is identity" do + one = Decimal.new(1) + + check all(a <- decimal(), max_runs: 100) do + assert Decimal.compare(Decimal.mult(a, one), a) == :eq + end + end + + property "mult/2 with zero is zero" do + zero = Decimal.new(0) + + check all(a <- decimal(), max_runs: 100) do + assert Decimal.compare(Decimal.mult(a, zero), zero) == :eq + end + end + + property "negate/1 is involutive" do + check all(a <- decimal(), max_runs: 100) do + assert Decimal.compare(Decimal.negate(Decimal.negate(a)), a) == :eq + end + end + + property "abs/1 of negation equals abs" do + check all(a <- decimal(), max_runs: 100) do + assert Decimal.compare(Decimal.abs(Decimal.negate(a)), Decimal.abs(a)) == :eq + end + end + + property "abs/1 is non-negative" do + zero = Decimal.new(0) + + check all(a <- decimal(), max_runs: 100) do + refute Decimal.compare(Decimal.abs(a), zero) == :lt + end + end + end + + describe "comparison predicates agree with compare/2" do + property "gt?/2" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + assert Decimal.gt?(a, b) == (Decimal.compare(a, b) == :gt) + end + end + + property "lt?/2" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + assert Decimal.lt?(a, b) == (Decimal.compare(a, b) == :lt) + end + end + + property "gte?/2" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + cmp = Decimal.compare(a, b) + assert Decimal.gte?(a, b) == (cmp == :gt or cmp == :eq) + end + end + + property "lte?/2" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + cmp = Decimal.compare(a, b) + assert Decimal.lte?(a, b) == (cmp == :lt or cmp == :eq) + end + end + + property "eq?/2" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + assert Decimal.eq?(a, b) == (Decimal.compare(a, b) == :eq) + end + end + + property "equal?/2" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + assert Decimal.equal?(a, b) == (Decimal.compare(a, b) == :eq) + end + end + end + + describe "min/2 and max/2" do + property "min/2 result is not greater than either input" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + m = Decimal.min(a, b) + refute Decimal.compare(m, a) == :gt + refute Decimal.compare(m, b) == :gt + end + end + + property "max/2 result is not less than either input" do + check all(a <- decimal(), b <- decimal(), max_runs: 100) do + m = Decimal.max(a, b) + refute Decimal.compare(m, a) == :lt + refute Decimal.compare(m, b) == :lt + end + end + end + + describe "normalize/1" do + property "is idempotent" do + check all(a <- decimal(), max_runs: 100) do + n = Decimal.normalize(a) + assert Decimal.normalize(n) == n + end + end + + property "preserves value" do + check all(a <- decimal(), max_runs: 100) do + assert Decimal.compare(Decimal.normalize(a), a) == :eq + end + end + end + + describe "sign predicates" do + property "positive?/1 agrees with compare/2 against zero" do + zero = Decimal.new(0) + + check all(a <- decimal(), max_runs: 100) do + assert Decimal.positive?(a) == (Decimal.compare(a, zero) == :gt) + end + end + + property "negative?/1 agrees with compare/2 against zero" do + zero = Decimal.new(0) + + check all(a <- decimal(), max_runs: 100) do + assert Decimal.negative?(a) == (Decimal.compare(a, zero) == :lt) + end + end + end + + describe "round-trip" do + property "to_string(:scientific) parses back to the same value" do + check all(a <- decimal(), max_runs: 100) do + s = Decimal.to_string(a, :scientific) + assert {parsed, ""} = Decimal.parse(s, max_digits: :infinity, max_exponent: :infinity) + assert Decimal.compare(parsed, a) == :eq + end + end + + property "Decimal.new/1 of an integer round-trips through to_integer/1" do + check all(n <- StreamData.integer(), max_runs: 100) do + d = Decimal.new(n) + assert Decimal.to_integer(d) == n + assert Decimal.integer?(d) + end + end + end + defp to_dec(float) when is_float(float), do: Decimal.from_float(float) defp to_dec(other), do: Decimal.new(other) diff --git a/test/decimal_test.exs b/test/decimal_test.exs index 9f5f325..70c399f 100644 --- a/test/decimal_test.exs +++ b/test/decimal_test.exs @@ -646,6 +646,12 @@ defmodule DecimalTest do assert Decimal.normalize(~d"nan") == d(1, :NaN, 0) end + test "normalize/1 with zero coefficient and non-zero exponent" do + assert Decimal.normalize(%Decimal{sign: 1, coef: 0, exp: -5}) == d(1, 0, 0) + assert Decimal.normalize(%Decimal{sign: -1, coef: 0, exp: -5_000}) == d(-1, 0, 0) + assert Decimal.normalize(%Decimal{sign: 1, coef: 0, exp: 5}) == d(1, 0, 0) + end + test "normalize/1 strips many trailing zeros without expansion" do coef = :erlang.binary_to_integer("123" <> String.duplicate("0", 5_000)) assert Decimal.normalize(%Decimal{sign: 1, coef: coef, exp: 0}) == d(1, 123, 5_000) @@ -841,6 +847,13 @@ defmodule DecimalTest do end) end + test "to_integer/1 with zero coefficient and negative exponent" do + assert Decimal.to_integer(~d"0.0") == 0 + assert Decimal.to_integer(~d"0.000") == 0 + assert Decimal.to_integer(~d"-0.0") == 0 + assert Decimal.to_integer(%Decimal{sign: 1, coef: 0, exp: -5_000}) == 0 + end + test "to_integer/1 with very large positive exponent" do assert Decimal.to_integer(%Decimal{sign: 1, coef: 7, exp: 5_000}) == 7 * :erlang.binary_to_integer("1" <> String.duplicate("0", 5_000)) @@ -854,6 +867,19 @@ defmodule DecimalTest do assert Decimal.to_integer(%Decimal{sign: -1, coef: coef, exp: -4_999}) == -10 end + property "to_integer/1 round-trips any integer through trailing-zero-padded encodings" do + check all( + n <- integer(), + k <- integer(0..500), + max_runs: 100 + ) do + sign = if n < 0, do: -1, else: 1 + coef = Kernel.abs(n) * Integer.pow(10, k) + decimal = %Decimal{sign: sign, coef: coef, exp: -k} + assert Decimal.to_integer(decimal) == n + end + end + test "to_integer/1 raises with normalized inspect" do # Loss-of-precision error inspects the normalized form (1.1, not 1.10). decimal = %Decimal{sign: 1, coef: 110, exp: -2} diff --git a/test/test_helper.exs b/test/test_helper.exs index 0cad62b..2a71c27 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -31,3 +31,48 @@ defmodule TestMacros do end end end + +defmodule DecimalGenerators do + @moduledoc """ + StreamData generators for `%Decimal{}` values. + + Defaults stay well inside the decimal128 context bounds so arithmetic + operations don't trigger overflow/underflow signals during property runs. + Tune `coef_max` / `exp_min` / `exp_max` per property when a wider or + narrower domain is needed. + """ + + @default_coef_max 10_000_000_000_000_000 + @default_exp_min -100 + @default_exp_max 100 + + def decimal(opts \\ []) do + build(0, opts) + end + + def non_zero_decimal(opts \\ []) do + build(1, opts) + end + + def non_negative_decimal(opts \\ []) do + build(0, Keyword.put(opts, :signs, [1])) + end + + def positive_decimal(opts \\ []) do + build(1, Keyword.put(opts, :signs, [1])) + end + + defp build(coef_min, opts) do + coef_max = Keyword.get(opts, :coef_max, @default_coef_max) + exp_min = Keyword.get(opts, :exp_min, @default_exp_min) + exp_max = Keyword.get(opts, :exp_max, @default_exp_max) + signs = Keyword.get(opts, :signs, [1, -1]) + + {StreamData.member_of(signs), StreamData.integer(coef_min..coef_max), + StreamData.integer(exp_min..exp_max)} + |> StreamData.tuple() + |> StreamData.map(fn {sign, coef, exp} -> + %Decimal{sign: sign, coef: coef, exp: exp} + end) + end +end