Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/decimal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
183 changes: 183 additions & 0 deletions test/decimal/property_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand Down
26 changes: 26 additions & 0 deletions test/decimal_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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}
Expand Down
45 changes: 45 additions & 0 deletions test/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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