From 96ca2c7046bcde30de6a91da65b5bedc620d3b9b Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Mon, 10 Mar 2025 11:19:00 +0100 Subject: [PATCH 1/6] refactor: contains_point?(a,x) can be written as contains?(a, `[x,x]`) --- lib/interval.ex | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/lib/interval.ex b/lib/interval.ex index 5bcfd7c..be3f877 100644 --- a/lib/interval.ex +++ b/lib/interval.ex @@ -875,25 +875,7 @@ defmodule Interval do @doc since: "0.1.4" @spec contains_point?(t(), point()) :: boolean() def contains_point?(%module{} = a, x) do - with true <- not empty?(a) do - contains_left = - unbounded_left?(a) or - case module.point_compare(left(a), x) do - :gt -> false - :eq -> inclusive_left?(a) - :lt -> true - end - - contains_right = - unbounded_right?(a) or - case module.point_compare(right(a), x) do - :gt -> true - :eq -> inclusive_right?(a) - :lt -> false - end - - contains_left and contains_right - end + contains?(a, new(module: module, left: x, right: x, bounds: "[]")) end @doc """ From b53a3c4d44839e6b6f13ceafbf5879cc4ad7dfaf Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Mon, 10 Mar 2025 11:28:56 +0100 Subject: [PATCH 2/6] refactor: compare_bounds/4 for strictly_[left/right]_of?/2 --- lib/interval.ex | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/interval.ex b/lib/interval.ex index be3f877..c28f057 100644 --- a/lib/interval.ex +++ b/lib/interval.ex @@ -551,11 +551,7 @@ defmodule Interval do not unbounded_left?(b) and not empty?(a) and not empty?(b) and - case module.point_compare(right(a), left(b)) do - :lt -> true - :eq -> not inclusive_right?(a) or not inclusive_left?(b) - :gt -> false - end + compare_bounds(:right, a, :left, b) == :lt end @doc """ @@ -593,11 +589,7 @@ defmodule Interval do not unbounded_right?(b) and not empty?(a) and not empty?(b) and - case module.point_compare(left(a), right(b)) do - :lt -> false - :eq -> not inclusive_left?(a) or not inclusive_right?(b) - :gt -> true - end + compare_bounds(:left, a, :right, b) == :gt end @doc """ From 3640d3d7c8db7c849e3771a37271b0ed8324e5cf Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Mon, 10 Mar 2025 12:21:36 +0100 Subject: [PATCH 3/6] refactor: Use compare_bounds more, and refactor tests Adding function `adjacent?/2` BREAKING CHANGE: `union/2` now raises when trying to union non-overlapping non-adjacent intervals as the result would be non-contiguous. before it returned an empty interval, but that's not how postgres behaves. --- lib/interval.ex | 50 ++++-- mix.exs | 4 + ... => continuous_interval_property_test.exs} | 75 +++++--- ...xs => discrete_interval_property_test.exs} | 78 ++++++--- test/{support => }/ecto_test.exs | 0 test/float_interval_property_test.exs | 126 -------------- test/support/helper.ex | 161 ++++++++++++++++++ test/test_helper.exs | 154 ----------------- 8 files changed, 305 insertions(+), 343 deletions(-) rename test/{decimal_interval_property_test.exs => continuous_interval_property_test.exs} (57%) rename test/{integer_interval_property_test.exs => discrete_interval_property_test.exs} (61%) rename test/{support => }/ecto_test.exs (100%) delete mode 100644 test/float_interval_property_test.exs create mode 100644 test/support/helper.ex diff --git a/lib/interval.ex b/lib/interval.ex index c28f057..22fe72e 100644 --- a/lib/interval.ex +++ b/lib/interval.ex @@ -703,6 +703,18 @@ defmodule Interval do end end + @doc """ + Check if two intervals are adjacent. + + Two intervals are adjacent if they do not overlap, and there are no points between them. + + This function is a shorthand for `adjacent_left_of(a, b) or adjacent_right_of?(a, b)`. + """ + @doc since: "2.0.0" + def adjacent?(a, b) do + adjacent_left_of?(a, b) or adjacent_right_of?(a, b) + end + @doc """ Does `a` overlap with `b`? @@ -900,7 +912,7 @@ defmodule Interval do new(module: Interval.IntegerInterval, left: 1, right: 3) iex> union(new(module: Interval.IntegerInterval, left: 1, right: 2), new(module: Interval.IntegerInterval, left: 3, right: 4)) - new(module: Interval.IntegerInterval, left: 0, right: 0) + ** (Interval.IntervalOperationError) cannot union non-overlapping non-adjacent intervals as the result would be non-contiguous """ @spec union(t(), t()) :: t() def union(%module{} = a, %module{} = b) do @@ -914,20 +926,26 @@ defmodule Interval do # if a and b overlap or are adjacent, we can union the intervals overlaps?(a, b) or adjacent_left_of?(a, b) or adjacent_right_of?(a, b) -> - left = pick_union_left(module, a.left, b.left) - right = pick_union_right(module, a.right, b.right) + left = + case compare_bounds(module, :left, a.left, :left, b.left) do + :lt -> a.left + _ -> b.left + end + + right = + case compare_bounds(module, :right, a.right, :right, b.right) do + :gt -> a.right + _ -> b.right + end from_endpoints(module, left, right) - # fall-through, if neither A or B is empty, - # but there is also no overlap or adjacency, - # then the two intervals are either strictly left or strictly right, - # we return empty (A and B share an empty amount of points) + # no overlap, not adjacent, not empty. + # We cannot union these intervals as the result would not be contiguous. true -> - # This assertion _must_ be true, since overlap?/2 returned false - # so there is no point in running it. - # true == strictly_left_of?(a, b) or strictly_right_of?(a, b) - new_empty(module) + raise IntervalOperationError, + message: + "cannot union non-overlapping non-adjacent intervals as the result would be non-contiguous" end end @@ -1047,7 +1065,15 @@ defmodule Interval do end end - @doc false + @doc """ + Compare the left/right side of `a` with the left/right side of `b` + + Returns `:lt | :gt | :eq` depending on `a`s relationship to `b`. + + Other interval operations use this function as primitive. + """ + @doc since: "2.0.0" + @spec compare_bounds(:left | :right, t(), :left | :right, t()) :: :lt | :eq | :gt def compare_bounds(a_side, %module{} = a, b_side, %module{} = b) do compare_bounds(module, a_side, Map.fetch!(a, a_side), b_side, Map.fetch!(b, b_side)) end diff --git a/mix.exs b/mix.exs index ceb7b35..4f08d0a 100644 --- a/mix.exs +++ b/mix.exs @@ -14,6 +14,7 @@ defmodule Interval.MixProject do """, version: @version, elixir: "~> 1.12", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, test_coverage: test_coverage(), preferred_cli_env: [check: :test], @@ -31,6 +32,9 @@ defmodule Interval.MixProject do ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + defp docs() do [ name: "Interval", diff --git a/test/decimal_interval_property_test.exs b/test/continuous_interval_property_test.exs similarity index 57% rename from test/decimal_interval_property_test.exs rename to test/continuous_interval_property_test.exs index 643c41d..344278e 100644 --- a/test/decimal_interval_property_test.exs +++ b/test/continuous_interval_property_test.exs @@ -1,38 +1,65 @@ -defmodule Interval.DecimalIntervalPropertyTest do - use ExUnit.Case, async: true +defmodule ContinuousIntervalPropertyTest do use ExUnitProperties - property "overlaps?/2 is commutative" do + use ExUnit.Case, + async: true, + parameterize: [ + %{impl: Interval.DecimalInterval}, + %{impl: Interval.FloatInterval} + ] + + property "overlaps?/2 is commutative", %{impl: impl} do check all( - a <- Helper.decimal_interval(), - b <- Helper.decimal_interval() + a <- Helper.interval(impl), + b <- Helper.interval(impl) ) do assert Interval.overlaps?(a, b) === Interval.overlaps?(b, a) end end - property "union/2 is commutative" do + property "union/2 is commutative", %{impl: impl} do check all( - a <- Helper.decimal_interval(), - b <- Helper.decimal_interval() + a <- Helper.interval(impl), + b <- Helper.interval(impl) ) do - assert Interval.union(a, b) === Interval.union(b, a) + cond do + Interval.overlaps?(a, b) -> + assert Interval.union(a, b) === Interval.union(b, a) + + Interval.adjacent?(a, b) -> + assert Interval.union(a, b) === Interval.union(b, a) + + Interval.empty?(a) and not Interval.empty?(b) -> + assert Interval.union(a, b) === b + + Interval.empty?(b) and not Interval.empty?(a) -> + assert Interval.union(a, b) === a + + Interval.empty?(a) and Interval.empty?(b) -> + assert Interval.empty?(Interval.union(a, b)) + assert Interval.empty?(Interval.union(b, a)) + + not Interval.empty?(b) and not Interval.empty?(a) -> + assert_raise Interval.IntervalOperationError, fn -> + Interval.union(a, b) + end + end end end - property "intersection/2 is commutative" do + property "intersection/2 is commutative", %{impl: impl} do check all( - a <- Helper.decimal_interval(), - b <- Helper.decimal_interval() + a <- Helper.interval(impl), + b <- Helper.interval(impl) ) do assert Interval.intersection(a, b) === Interval.intersection(b, a) end end - property "intersection/2's relationship with contains?/2" do + property "intersection/2's relationship with contains?/2", %{impl: impl} do check all( - a <- Helper.decimal_interval(), - b <- Helper.decimal_interval() + a <- Helper.interval(impl), + b <- Helper.interval(impl) ) do intersection = Interval.intersection(a, b) @@ -51,10 +78,10 @@ defmodule Interval.DecimalIntervalPropertyTest do end end - property "contains?/2" do + property "contains?/2", %{impl: impl} do check all( - a <- Helper.decimal_interval(), - b <- Helper.decimal_interval() + a <- Helper.interval(impl), + b <- Helper.interval(impl) ) do cond do Interval.empty?(a) and not Interval.empty?(b) -> @@ -80,10 +107,10 @@ defmodule Interval.DecimalIntervalPropertyTest do end end - property "strictly_left_of?/2 and strictly_right_of?/2 relationship" do + property "strictly_left_of?/2 and strictly_right_of?/2 relationship", %{impl: impl} do check all( - a <- Helper.decimal_interval(empty: false), - b <- Helper.decimal_interval(empty: false) + a <- Helper.interval(impl, empty: false), + b <- Helper.interval(impl, empty: false) ) do if Interval.overlaps?(a, b) do refute Interval.strictly_left_of?(a, b) @@ -102,10 +129,10 @@ defmodule Interval.DecimalIntervalPropertyTest do end end - property "adjacent_left_of?/2 and adjacent_right_of?/2 relationship" do + property "adjacent_left_of?/2 and adjacent_right_of?/2 relationship", %{impl: impl} do check all( - a <- Helper.decimal_interval(empty: false), - b <- Helper.decimal_interval(empty: false) + a <- Helper.interval(impl, empty: false), + b <- Helper.interval(impl, empty: false) ) do if Interval.overlaps?(a, b) do refute Interval.adjacent_left_of?(a, b) diff --git a/test/integer_interval_property_test.exs b/test/discrete_interval_property_test.exs similarity index 61% rename from test/integer_interval_property_test.exs rename to test/discrete_interval_property_test.exs index d92fa43..28f1e0f 100644 --- a/test/integer_interval_property_test.exs +++ b/test/discrete_interval_property_test.exs @@ -1,40 +1,64 @@ -defmodule IntegerIntervalIntervalPropertyTest do - use ExUnit.Case, async: true +defmodule DiscreteIntervalPropertyTest do use ExUnitProperties - alias Interval.IntegerInterval + use ExUnit.Case, + async: true, + parameterize: [ + %{impl: Interval.IntegerInterval} + ] - property "overlaps?/2 is commutative" do + property "overlaps?/2 is commutative", %{impl: impl} do check all( - a <- Helper.integer_interval(), - b <- Helper.integer_interval() + a <- Helper.interval(impl), + b <- Helper.interval(impl) ) do assert Interval.overlaps?(a, b) === Interval.overlaps?(b, a) end end - property "union/2 is commutative" do + property "union/2 is commutative", %{impl: impl} do check all( - a <- Helper.integer_interval(), - b <- Helper.integer_interval() + a <- Helper.interval(impl), + b <- Helper.interval(impl) ) do - assert Interval.union(a, b) === Interval.union(b, a) + cond do + Interval.overlaps?(a, b) -> + assert Interval.union(a, b) === Interval.union(b, a) + + Interval.adjacent?(a, b) -> + assert Interval.union(a, b) === Interval.union(b, a) + + Interval.empty?(a) and not Interval.empty?(b) -> + assert Interval.union(a, b) === b + + Interval.empty?(b) and not Interval.empty?(a) -> + assert Interval.union(a, b) === a + + Interval.empty?(a) and Interval.empty?(b) -> + assert Interval.empty?(Interval.union(a, b)) + assert Interval.empty?(Interval.union(b, a)) + + not Interval.empty?(b) and not Interval.empty?(a) -> + assert_raise Interval.IntervalOperationError, fn -> + Interval.union(a, b) + end + end end end - property "intersection/2 is commutative" do + property "intersection/2 is commutative", %{impl: impl} do check all( - a <- Helper.integer_interval(), - b <- Helper.integer_interval() + a <- Helper.interval(impl), + b <- Helper.interval(impl) ) do assert Interval.intersection(a, b) === Interval.intersection(b, a) end end - property "intersection/2's relationship with contains?/2" do + property "intersection/2's relationship with contains?/2", %{impl: impl} do check all( - a <- Helper.integer_interval(), - b <- Helper.integer_interval() + a <- Helper.interval(impl), + b <- Helper.interval(impl) ) do intersection = Interval.intersection(a, b) @@ -53,10 +77,10 @@ defmodule IntegerIntervalIntervalPropertyTest do end end - property "contains?/2 & contains_point?/2" do + property "contains?/2 & contains_point?/2", %{impl: impl} do check all( - a <- Helper.integer_interval(), - b <- Helper.integer_interval() + a <- Helper.interval(impl), + b <- Helper.interval(impl) ) do cond do Interval.empty?(a) and not Interval.empty?(b) -> @@ -90,7 +114,7 @@ defmodule IntegerIntervalIntervalPropertyTest do :ok {:exclusive, p} -> - assert Interval.contains_point?(a, IntegerInterval.point_step(p, -1)) + assert Interval.contains_point?(a, impl.point_step(p, -1)) end end @@ -105,17 +129,17 @@ defmodule IntegerIntervalIntervalPropertyTest do :ok {:exclusive, p} -> - assert Interval.contains_point?(b, IntegerInterval.point_step(p, -1)) + assert Interval.contains_point?(b, impl.point_step(p, -1)) end end end end end - property "strictly_left_of?/2 and strictly_right_of?/2 relationship" do + property "strictly_left_of?/2 and strictly_right_of?/2 relationship", %{impl: impl} do check all( - a <- Helper.integer_interval(empty: false), - b <- Helper.integer_interval(empty: false) + a <- Helper.interval(impl, empty: false), + b <- Helper.interval(impl, empty: false) ) do if Interval.overlaps?(a, b) do refute Interval.strictly_left_of?(a, b) @@ -134,10 +158,10 @@ defmodule IntegerIntervalIntervalPropertyTest do end end - property "adjacent_left_of?/2 and adjacent_right_of?/2 relationship" do + property "adjacent_left_of?/2 and adjacent_right_of?/2 relationship", %{impl: impl} do check all( - a <- Helper.integer_interval(empty: false), - b <- Helper.integer_interval(empty: false) + a <- Helper.interval(impl, empty: false), + b <- Helper.interval(impl, empty: false) ) do if Interval.overlaps?(a, b) do refute Interval.adjacent_left_of?(a, b) diff --git a/test/support/ecto_test.exs b/test/ecto_test.exs similarity index 100% rename from test/support/ecto_test.exs rename to test/ecto_test.exs diff --git a/test/float_interval_property_test.exs b/test/float_interval_property_test.exs deleted file mode 100644 index 0e3c4e0..0000000 --- a/test/float_interval_property_test.exs +++ /dev/null @@ -1,126 +0,0 @@ -defmodule Interval.FloatIntervalPropertyTest do - use ExUnit.Case, async: true - use ExUnitProperties - - property "overlaps?/2 is commutative" do - check all( - a <- Helper.float_interval(), - b <- Helper.float_interval() - ) do - assert Interval.overlaps?(a, b) === Interval.overlaps?(b, a) - end - end - - property "union/2 is commutative" do - check all( - a <- Helper.float_interval(), - b <- Helper.float_interval() - ) do - assert Interval.union(a, b) === Interval.union(b, a) - end - end - - property "intersection/2 is commutative" do - check all( - a <- Helper.float_interval(), - b <- Helper.float_interval() - ) do - assert Interval.intersection(a, b) === Interval.intersection(b, a) - end - end - - property "intersection/2's relationship with contains?/2" do - check all( - a <- Helper.float_interval(), - b <- Helper.float_interval() - ) do - intersection = Interval.intersection(a, b) - - cond do - # when A contains B (or later, B contains A) then the - # intersection between the two intervals will be the contained one. - Interval.contains?(a, b) -> - assert intersection === b - - Interval.contains?(b, a) -> - assert intersection === a - - true -> - assert Interval.intersection(a, b) === Interval.intersection(b, a) - end - end - end - - property "contains?/2" do - check all( - a <- Helper.float_interval(), - b <- Helper.float_interval() - ) do - cond do - Interval.empty?(a) and not Interval.empty?(b) -> - refute Interval.contains?(a, b) - - Interval.empty?(b) -> - assert Interval.contains?(a, b) - - a == b -> - assert Interval.contains?(a, b) - assert Interval.contains?(b, a) - - a != b -> - a_contains_b = Interval.contains?(a, b) - b_contains_a = Interval.contains?(b, a) - - assert( - (a_contains_b and not b_contains_a) or - (b_contains_a and not a_contains_b) or - (not a_contains_b and not b_contains_a) - ) - end - end - end - - property "strictly_left_of?/2 and strictly_right_of?/2 relationship" do - check all( - a <- Helper.float_interval(empty: false), - b <- Helper.float_interval(empty: false) - ) do - if Interval.overlaps?(a, b) do - refute Interval.strictly_left_of?(a, b) - refute Interval.strictly_right_of?(a, b) - else - if Interval.strictly_left_of?(a, b) do - refute Interval.overlaps?(a, b) - refute Interval.strictly_right_of?(a, b) - end - - if Interval.strictly_right_of?(a, b) do - refute Interval.overlaps?(a, b) - refute Interval.strictly_left_of?(a, b) - end - end - end - end - - property "adjacent_left_of?/2 and adjacent_right_of?/2 relationship" do - check all( - a <- Helper.float_interval(empty: false), - b <- Helper.float_interval(empty: false) - ) do - if Interval.overlaps?(a, b) do - refute Interval.adjacent_left_of?(a, b) - refute Interval.adjacent_right_of?(a, b) - else - if Interval.adjacent_left_of?(a, b) do - refute Interval.overlaps?(a, b) - refute Interval.adjacent_right_of?(a, b) - end - - if Interval.adjacent_right_of?(a, b) do - refute Interval.overlaps?(a, b) - refute Interval.adjacent_left_of?(a, b) - end - end - end - end -end diff --git a/test/support/helper.ex b/test/support/helper.ex new file mode 100644 index 0000000..0d1d9da --- /dev/null +++ b/test/support/helper.ex @@ -0,0 +1,161 @@ +defmodule Helper do + @moduledoc """ + Generators for property testing + """ + + alias Interval.FloatInterval + alias Interval.DecimalInterval + alias Interval.IntegerInterval + + import ExUnitProperties + + def bounds() do + StreamData.one_of( + ["[]", "[)", "(]", "()"] + |> Enum.map(&StreamData.constant/1) + ) + end + + def bounded_interval(module, left_generator, offset_generator) do + gen all( + left <- left_generator, + offset <- offset_generator, + bounds <- bounds() + ) do + # to avoid producing empty intervals, + # we check that the bounds and ensure that we cannot produce + # an interval that would normalize to empty + case module.discrete?() do + true -> + offset = + case bounds do + # both inclusive, we are OK with offset being 0 + "[]" -> module.point_step(offset, -1) + # if either bound is inclusive, + "(]" -> offset + # the default of offset `[1,` is OK + "[)" -> offset + # both exclusive, so offset must be at least 2 to not produce empty + "()" -> module.point_step(offset, +1) + end + + Interval.new( + module: module, + left: left, + right: add_offset(left, offset), + bounds: bounds + ) + + false -> + Interval.new( + module: module, + left: left, + right: add_offset(left, offset), + bounds: bounds + ) + end + + Interval.new(module: module, left: left, right: add_offset(left, offset), bounds: bounds) + end + end + + def unbounded_interval(module, point_generator) do + gen all( + point <- point_generator, + point_is_left <- StreamData.boolean(), + bounds <- bounds() + ) do + opts = + if point_is_left do + [module: module, left: point, right: nil, bounds: bounds] + else + [module: module, left: nil, right: point, bounds: bounds] + end + + Interval.new(opts) + end + end + + defp add_offset(value, offset) when is_integer(value), do: value + offset + defp add_offset(value, offset) when is_float(value), do: value + offset + + defp add_offset(value, offset) when is_struct(value, Decimal) do + Decimal.add(value, offset) + |> Decimal.normalize() + end + + def empty_interval(module) do + StreamData.constant(module.new(left: :empty, right: :empty)) + end + + def interval(module, opts \\ []) do + case module do + IntegerInterval -> integer_interval(opts) + FloatInterval -> float_interval(opts) + DecimalInterval -> decimal_interval(opts) + end + end + + def integer_interval(opts \\ []) do + opts = Keyword.merge([unbounded: true, bounded: true, empty: true], opts) + i = StreamData.integer() + pi = StreamData.positive_integer() + + StreamData.one_of( + [ + if(Keyword.get(opts, :bounded), do: bounded_interval(IntegerInterval, i, pi)), + if(Keyword.get(opts, :unbounded), do: unbounded_interval(IntegerInterval, i)), + if(Keyword.get(opts, :empty), do: empty_interval(IntegerInterval)) + ] + |> Enum.reject(&is_nil/1) + ) + end + + def float_interval(opts \\ []) do + opts = Keyword.merge([unbounded: true, bounded: true, empty: true], opts) + f = StreamData.float() + pf = StreamData.float(min: 0.1) + + StreamData.one_of( + [ + if(Keyword.get(opts, :bounded), do: bounded_interval(FloatInterval, f, pf)), + if(Keyword.get(opts, :unbounded), do: unbounded_interval(FloatInterval, f)), + if(Keyword.get(opts, :empty), do: empty_interval(FloatInterval)) + ] + |> Enum.reject(&is_nil/1) + ) + end + + def decimal_interval(opts \\ []) do + opts = Keyword.merge([unbounded: true, bounded: true, empty: true], opts) + zero = Decimal.new(0) + + f = + StreamData.float() + |> StreamData.map(&Decimal.from_float/1) + |> StreamData.map(&Decimal.normalize/1) + |> StreamData.map(fn value -> + # When -0 is generated, 0 and -0 isn't the same + # term, and the test assertions get's annoying to do. + # Instead, just prevent -0 from being generated. + case Decimal.compare(value, zero) do + :eq -> zero + _ -> value + end + end) + + pf = + StreamData.float(min: 0.1) + |> StreamData.map(&Decimal.from_float/1) + |> StreamData.map(&Decimal.normalize/1) + + StreamData.one_of( + [ + if(Keyword.get(opts, :bounded), do: bounded_interval(DecimalInterval, f, pf)), + if(Keyword.get(opts, :unbounded), do: unbounded_interval(DecimalInterval, f)), + if(Keyword.get(opts, :empty), do: empty_interval(DecimalInterval)) + ] + |> Enum.reject(&is_nil/1) + ) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 4109cdb..869559e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,155 +1 @@ ExUnit.start() - -defmodule Helper do - @moduledoc """ - Generators for property testing - """ - - alias Interval.FloatInterval - alias Interval.DecimalInterval - alias Interval.IntegerInterval - - import ExUnitProperties - - def bounds() do - StreamData.one_of( - ["[]", "[)", "(]", "()"] - |> Enum.map(&StreamData.constant/1) - ) - end - - def bounded_interval(module, left_generator, offset_generator) do - gen all( - left <- left_generator, - offset <- offset_generator, - bounds <- bounds() - ) do - # to avoid producing empty intervals, - # we check that the bounds and ensure that we cannot produce - # an interval that would normalize to empty - case module.discrete?() do - true -> - offset = - case bounds do - # both inclusive, we are OK with offset being 0 - "[]" -> module.point_step(offset, -1) - # if either bound is inclusive, - "(]" -> offset - # the default of offset `[1,` is OK - "[)" -> offset - # both exclusive, so offset must be at least 2 to not produce empty - "()" -> module.point_step(offset, +1) - end - - Interval.new( - module: module, - left: left, - right: add_offset(left, offset), - bounds: bounds - ) - - false -> - Interval.new( - module: module, - left: left, - right: add_offset(left, offset), - bounds: bounds - ) - end - - Interval.new(module: module, left: left, right: add_offset(left, offset), bounds: bounds) - end - end - - def unbounded_interval(module, point_generator) do - gen all( - point <- point_generator, - point_is_left <- StreamData.boolean(), - bounds <- bounds() - ) do - opts = - if point_is_left do - [module: module, left: point, right: nil, bounds: bounds] - else - [module: module, left: nil, right: point, bounds: bounds] - end - - Interval.new(opts) - end - end - - defp add_offset(value, offset) when is_integer(value), do: value + offset - defp add_offset(value, offset) when is_float(value), do: value + offset - - defp add_offset(value, offset) when is_struct(value, Decimal) do - Decimal.add(value, offset) - |> Decimal.normalize() - end - - def empty_interval(module) do - StreamData.constant(module.new(left: :empty, right: :empty)) - end - - def integer_interval(opts \\ []) do - opts = Keyword.merge([unbounded: true, bounded: true, empty: true], opts) - i = StreamData.integer() - pi = StreamData.positive_integer() - - StreamData.one_of( - [ - if(Keyword.get(opts, :bounded), do: bounded_interval(IntegerInterval, i, pi)), - if(Keyword.get(opts, :unbounded), do: unbounded_interval(IntegerInterval, i)), - if(Keyword.get(opts, :empty), do: empty_interval(IntegerInterval)) - ] - |> Enum.reject(&is_nil/1) - ) - end - - def float_interval(opts \\ []) do - opts = Keyword.merge([unbounded: true, bounded: true, empty: true], opts) - f = StreamData.float() - pf = StreamData.float(min: 0.1) - - StreamData.one_of( - [ - if(Keyword.get(opts, :bounded), do: bounded_interval(FloatInterval, f, pf)), - if(Keyword.get(opts, :unbounded), do: unbounded_interval(FloatInterval, f)), - if(Keyword.get(opts, :empty), do: empty_interval(FloatInterval)) - ] - |> Enum.reject(&is_nil/1) - ) - end - - def decimal_interval(opts \\ []) do - opts = Keyword.merge([unbounded: true, bounded: true, empty: true], opts) - zero = Decimal.new(0) - - f = - StreamData.float() - |> StreamData.map(&Decimal.from_float/1) - |> StreamData.map(&Decimal.normalize/1) - |> StreamData.map(fn value -> - # When -0 is generated, 0 and -0 isn't the same - # term, and the test assertions get's annoying to do. - # Instead, just prevent -0 from being generated. - case Decimal.compare(value, zero) do - :eq -> zero - _ -> value - end - end) - - pf = - StreamData.float(min: 0.1) - |> StreamData.map(&Decimal.from_float/1) - |> StreamData.map(&Decimal.normalize/1) - - StreamData.one_of( - [ - if(Keyword.get(opts, :bounded), do: bounded_interval(DecimalInterval, f, pf)), - if(Keyword.get(opts, :unbounded), do: unbounded_interval(DecimalInterval, f)), - if(Keyword.get(opts, :empty), do: empty_interval(DecimalInterval)) - ] - |> Enum.reject(&is_nil/1) - ) - end -end From 08b46209060d9ca2f9d8ae409d3789c313fa4320 Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Mon, 10 Mar 2025 13:19:52 +0100 Subject: [PATCH 4/6] refactor: clean up and add new options `:empty` to `Interval.new/1` This is in preparation for the next release. The next release is breaking change, so we'll bump major version. --- CHANGELOG.md | 6 +- config/test.exs | 2 +- lib/interval.ex | 275 ++++++++++++++++-------------------------------- 3 files changed, 98 insertions(+), 185 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb3a0a..cb7334b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 2.0.0-alpha.1 ### Added - `exclusive_left?/1` and `exclusive_right?/1` +- `adjacent?/2` (which just checks for adjacency in both directions) +- `new/1` now accepts option `empty: boolean()` +- `union/2` will raise `Interval.IntervalOperationError` when the intervals are disjoint. ### Fixed - `contains?/2` bug - issue #29 - `contains?/2` bug - empty interval was never contained by anything which doesn't align with Postgres. + ## [1.0.0] - 2025-03-04 ### Added diff --git a/config/test.exs b/config/test.exs index 7d70bd7..29cf845 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,3 +1,3 @@ import Config -config :stream_data, max_runs: 200 +config :stream_data, max_runs: 250 diff --git a/lib/interval.ex b/lib/interval.ex index 22fe72e..3069e96 100644 --- a/lib/interval.ex +++ b/lib/interval.ex @@ -208,18 +208,18 @@ defmodule Interval do ## Options + + - `left` The left (or lower) endpoint value of the interval (default: `:unbounded`) + - `right` The right (or upper) endpoint value of the interval (default: `:unbounded`) + - `bounds` The bound mode to use (default: `"[)"`) + - `empty` If set to `true`, the interval will be empty (default: `false`) - `module` The interval implementation to use. - When calling `new/1` from a `Interval.Behaviour` this is inferred. - - `left` The left (or lower) endpoint of the interval - - `right` The right (or upper) endpoint of the interval - - `bounds` The bound mode to use. Defaults to `"[)"` + When calling `new/1` from an `Interval.Behaviour` this is inferred. - A `nil` (`left` or `right`) endpoint is considered unbounded. - The endpoint will also be considered unbounded if the `bounds` is explicitly - set as unbounded. + Specifying `left` or `right` as `nil` will be interpreted as `:unbounded`. + The endpoint will also be considered unbounded if the `bounds` explicitly sets it as unbounded. - A special value `:empty` can be given to `left` and `right` to - construct an empty interval. + Specifying `left` or `right` as `:empty` will create an empty interval. ## Bounds @@ -241,7 +241,7 @@ defmodule Interval do iex> new(module: Interval.IntegerInterval) - iex> new(module: Interval.IntegerInterval, left: :empty, right: :empty) + iex> new(module: Interval.IntegerInterval, empty: true) iex> new(module: Interval.IntegerInterval, left: 1) @@ -252,20 +252,28 @@ defmodule Interval do @spec new(Keyword.t()) :: t() def new(opts) when is_list(opts) do module = Keyword.fetch!(opts, :module) - left = Keyword.get(opts, :left, nil) - right = Keyword.get(opts, :right, nil) - bounds = Keyword.get(opts, :bounds, nil) - {left_bound, right_bound} = unpack_bounds(bounds) - - left_endpoint = normalize_endpoint(module, left, left_bound) - right_endpoint = normalize_endpoint(module, right, right_bound) - normalize(struct!(module, left: left_endpoint, right: right_endpoint)) + empty = Keyword.get(opts, :empty, false) + left = with nil <- Keyword.get(opts, :left), do: :unbounded + right = with nil <- Keyword.get(opts, :right), do: :unbounded + bounds = with nil <- Keyword.get(opts, :bounds), do: "[)" + + if empty == true or left == :empty or right == :empty do + # if we need to create an empty struct, we can short-circuit to an empty: + struct!(module, left: :empty, right: :empty) + else + # otherwise we need to do bounds checking and normalization: + {left_bound, right_bound} = unpack_bounds(bounds) + left_endpoint = normalize_endpoint(module, left, left_bound) + right_endpoint = normalize_endpoint(module, right, right_bound) + normalize(struct!(module, left: left_endpoint, right: right_endpoint)) + end end defp normalize_endpoint(module, point, bound) do case {point, bound} do - {:empty, _} -> :empty - {nil, _} -> :unbounded + # point value takes precedence over bound: + {:unbounded, _} -> :unbounded + # if the point is set, the bound value discribes bound-ness: {_, :unbounded} -> :unbounded {_, :inclusive} -> {:inclusive, normalize_point!(module, point)} {_, :exclusive} -> {:exclusive, normalize_point!(module, point)} @@ -634,14 +642,11 @@ defmodule Interval do not empty?(b) with true <- prerequisite do - # Assuming we've normalized both a and b, - # if the point types are discrete, and and normalized to `[)` - # then continuous and discrete intervals are checked in the same way. - # To ensure we don't give the wrong answer though, - # we have an assertion that that a discrete point type must be - # bounded as `[)`: - assert_normalized_bounds(a) - assert_normalized_bounds(b) + if module.discrete?() do + # for discrete types, to detect adjacency, we need to ensure normalized bounds. + assert_bounds(a, "[)") + assert_bounds(b, "[)") + end inclusive_right?(a) != inclusive_left?(b) and module.point_compare(right(a), left(b)) == :eq @@ -689,14 +694,11 @@ defmodule Interval do not empty?(b) with true <- prerequisite do - # Assuming we've normalized both a and b, - # if the point types are discrete, and and normalized to `[)` - # then continuous and discrete intervals are checked in the same way. - # To ensure we don't give the wrong answer though, - # we have an assertion that that a discrete point type must be - # bounded as `[)`: - assert_normalized_bounds(a) - assert_normalized_bounds(b) + if module.discrete?() do + # for discrete types, to detect adjacency, we need to ensure normalized bounds. + assert_bounds(a, "[)") + assert_bounds(b, "[)") + end module.point_compare(left(a), right(b)) == :eq and inclusive_left?(a) != inclusive_right?(b) @@ -1020,10 +1022,21 @@ defmodule Interval do # otherwise, we can compute the intersection true -> - # The intersection between `a` and `b` is the points that exist in - # both `a` and `b`. - left = pick_intersection_left(module, a.left, b.left) - right = pick_intersection_right(module, a.right, b.right) + # The intersection between `a` and `b` is the points that exist in both `a` and `b`. + # Since we know they overlap, we can just pick the left-most right bound and the right-most left bound. + + left = + case compare_bounds(module, :left, a.left, :left, b.left) do + :lt -> b.left + _ -> a.left + end + + right = + case compare_bounds(module, :right, a.right, :right, b.right) do + :gt -> b.right + _ -> a.right + end + from_endpoints(module, left, right) end end @@ -1136,40 +1149,25 @@ defmodule Interval do end defp from_endpoints(module, left, right) do - left_bound = - case left do - :unbounded -> :unbounded - {:exclusive, _} -> :exclusive - {:inclusive, _} -> :inclusive - end - - right_bound = - case right do - :unbounded -> :unbounded - {:exclusive, _} -> :exclusive - {:inclusive, _} -> :inclusive - end - - left_point = - case left do - :unbounded -> nil - {_, point} -> point - end - - right_point = - case right do - :unbounded -> nil - {_, point} -> point - end - new( module: module, - left: left_point, - right: right_point, - bounds: pack_bounds({left_bound, right_bound}) + left: point(left), + right: point(right), + bounds: pack_bounds({bound(left), bound(right)}) ) end + defp new_empty(module) do + module.new(empty: true) + end + + defp bound(:unbounded), do: :unbounded + defp bound({:exclusive, _}), do: :exclusive + defp bound({:inclusive, _}), do: :inclusive + + defp point(:unbounded), do: nil + defp point({_, point}), do: point + defp normalize_point!(_module, :empty), do: :empty defp normalize_point!(_module, nil), do: nil @@ -1223,125 +1221,36 @@ defmodule Interval do end end - # Pick the exclusive endpoint if it exists - defp pick_exclusive({:exclusive, _} = a, _), do: a - defp pick_exclusive(_, {:exclusive, _} = b), do: b - defp pick_exclusive(a, b) when a < b, do: b - defp pick_exclusive(a, _b), do: a - - # Pick the inclusive endpoint if it exists - defp pick_inclusive({:inclusive, _} = a, _), do: a - defp pick_inclusive(_, {:inclusive, _} = b), do: b - defp pick_inclusive(a, b) when a < b, do: b - defp pick_inclusive(a, _b), do: a - - # Pick the left point of a union from two left points - defp pick_union_left(_, :unbounded, _), do: :unbounded - defp pick_union_left(_, _, :unbounded), do: :unbounded - - defp pick_union_left(module, a, b) do - case module.point_compare(point(a), point(b)) do - :gt -> b - :lt -> a - :eq -> pick_inclusive(a, b) - end - end - - # Pick the right point of a union from two right points - defp pick_union_right(_, :unbounded, _), do: :unbounded - defp pick_union_right(_, _, :unbounded), do: :unbounded - - defp pick_union_right(module, a, b) do - case module.point_compare(point(a), point(b)) do - :gt -> a - :lt -> b - :eq -> pick_inclusive(a, b) - end - end - - # Pick the left point of a intersection from two left points - defp pick_intersection_left(_, :unbounded, :unbounded), do: :unbounded - defp pick_intersection_left(_, a, :unbounded), do: a - defp pick_intersection_left(_, :unbounded, b), do: b - - defp pick_intersection_left(module, a, b) do - case module.point_compare(point(a), point(b)) do - :gt -> a - :lt -> b - :eq -> pick_exclusive(a, b) - end - end - - # Pick the right point of a intersection from two right points - defp pick_intersection_right(_, :unbounded, :unbounded), do: :unbounded - defp pick_intersection_right(_, a, :unbounded), do: a - defp pick_intersection_right(_, :unbounded, b), do: b - - defp pick_intersection_right(module, a, b) do - case module.point_compare(point(a), point(b)) do - :gt -> b - :lt -> a - :eq -> pick_exclusive(a, b) - end + @bounds %{ + "" => {:unbounded, :unbounded}, + ")" => {:unbounded, :exclusive}, + "(" => {:exclusive, :unbounded}, + "]" => {:unbounded, :inclusive}, + "[" => {:inclusive, :unbounded}, + "()" => {:exclusive, :exclusive}, + "[]" => {:inclusive, :inclusive}, + "[)" => {:inclusive, :exclusive}, + "(]" => {:exclusive, :inclusive} + } + + for {str, tuple} <- @bounds do + defp unpack_bounds(unquote(str)), do: unquote(tuple) + defp pack_bounds(unquote(tuple)), do: unquote(str) end - # completely unbounded: - defp unpack_bounds(nil), do: unpack_bounds("[)") - defp unpack_bounds(""), do: {:unbounded, :unbounded} - # unbounded either left or right - defp unpack_bounds(")"), do: {:unbounded, :exclusive} - defp unpack_bounds("("), do: {:exclusive, :unbounded} - defp unpack_bounds("]"), do: {:unbounded, :inclusive} - defp unpack_bounds("["), do: {:inclusive, :unbounded} - # bounded both sides - defp unpack_bounds("()"), do: {:exclusive, :exclusive} - defp unpack_bounds("[]"), do: {:inclusive, :inclusive} - defp unpack_bounds("[)"), do: {:inclusive, :exclusive} - defp unpack_bounds("(]"), do: {:exclusive, :inclusive} - - defp pack_bounds({:unbounded, :unbounded}), do: "" - # unbounded either left or right - defp pack_bounds({:unbounded, :exclusive}), do: ")" - defp pack_bounds({:exclusive, :unbounded}), do: "(" - defp pack_bounds({:unbounded, :inclusive}), do: "]" - defp pack_bounds({:inclusive, :unbounded}), do: "[" - # bounded both sides - defp pack_bounds({:exclusive, :exclusive}), do: "()" - defp pack_bounds({:inclusive, :inclusive}), do: "[]" - defp pack_bounds({:inclusive, :exclusive}), do: "[)" - defp pack_bounds({:exclusive, :inclusive}), do: "(]" - - defp new_empty(module) do - module.new(left: :empty, right: :empty) + defp assert_bounds(%{} = a, bounds) when is_binary(bounds) do + assert_bounds(a, unpack_bounds(bounds)) end - # Endpoint value extraction: - defp point({_, point}), do: point - - # Left is bounded and has a point - defp assert_normalized_bounds(%module{left: {_, _}} = a) do - assert_normalized_bounds(a, module.discrete?()) - end - - # right is bounded and has a point - defp assert_normalized_bounds(%module{right: {_, _}} = a) do - assert_normalized_bounds(a, module.discrete?()) - end - - defp assert_normalized_bounds(%module{} = a, true) do - left_ok = unbounded_left?(a) or inclusive_left?(a) - right_ok = unbounded_right?(a) or not inclusive_right?(a) - - if not (left_ok and right_ok) do - raise ArgumentError, - message: - "non-normalized discrete interval #{module}: #{inspect(a)} " <> - "(expected normalized bounds `[)`)" - end - end + defp assert_bounds(%{left: :empty, right: :empty}, {_left, _right}), do: :ok + defp assert_bounds(%{left: {left, _}, right: {right, _}}, {left, right}), do: :ok + defp assert_bounds(%{left: {left, _}, right: :unbounded}, {left, _right}), do: :ok + defp assert_bounds(%{left: :unbounded, right: {right, _}}, {_left, right}), do: :ok + defp assert_bounds(%{left: :unbounded, right: :unbounded}, {_left, _right}), do: :ok - defp assert_normalized_bounds(_a, _discrete) do - nil + defp assert_bounds(a, bounds) do + raise ArgumentError, + message: "expected bounds #{pack_bounds(bounds)} for interval #{inspect(a)}" end ## From 80a738d85072a83a8731561708826eb8d4cf0b36 Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Mon, 10 Mar 2025 13:36:48 +0100 Subject: [PATCH 5/6] test: allow tests to run on older versions of ExUnit without parameterize --- test/continuous_interval_property_test.exs | 6 ++++++ test/discrete_interval_property_test.exs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/test/continuous_interval_property_test.exs b/test/continuous_interval_property_test.exs index 344278e..de15379 100644 --- a/test/continuous_interval_property_test.exs +++ b/test/continuous_interval_property_test.exs @@ -8,6 +8,12 @@ defmodule ContinuousIntervalPropertyTest do %{impl: Interval.FloatInterval} ] + setup ctx do + # older versions of ExUnit do not support parameterize, + # so we fall back to a signle continuous module to test + Map.put_new(ctx, :impl, Interval.FloatInterval) + end + property "overlaps?/2 is commutative", %{impl: impl} do check all( a <- Helper.interval(impl), diff --git a/test/discrete_interval_property_test.exs b/test/discrete_interval_property_test.exs index 28f1e0f..8345381 100644 --- a/test/discrete_interval_property_test.exs +++ b/test/discrete_interval_property_test.exs @@ -7,6 +7,12 @@ defmodule DiscreteIntervalPropertyTest do %{impl: Interval.IntegerInterval} ] + setup ctx do + # older versions of ExUnit do not support parameterize, + # so we fall back to a signle discrete module to test + Map.put_new(ctx, :impl, Interval.IntegerInterval) + end + property "overlaps?/2 is commutative", %{impl: impl} do check all( a <- Helper.interval(impl), From 8ee2b0b2b9f90306aa63f07450c35d414b7d5c2b Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Mon, 10 Mar 2025 14:21:11 +0100 Subject: [PATCH 6/6] test: significantly increase test coverage Coverage is now close to 100%, even without the property tests. --- lib/interval.ex | 10 +-- mix.exs | 1 + test/builtin_test.exs | 68 ++++++++++++++++++++ test/interval_test.exs | 142 +++++++++++++++++++++++++++++++++++++---- 4 files changed, 200 insertions(+), 21 deletions(-) diff --git a/lib/interval.ex b/lib/interval.ex index 3069e96..1b25687 100644 --- a/lib/interval.ex +++ b/lib/interval.ex @@ -308,13 +308,14 @@ defmodule Interval do """ @spec empty?(t()) :: boolean() def empty?(a) - def empty?(%{left: :unbounded}), do: false - def empty?(%{right: :unbounded}), do: false # if either side is empty, the interval is empty (normalized form will ensure both are set empty) def empty?(%{left: :empty}), do: true def empty?(%{right: :empty}), do: true + def empty?(%{left: :unbounded}), do: false + def empty?(%{right: :unbounded}), do: false + # If the interval is not properly normalized, we have to check for all possible combinations. # an interval is empty if it spans a single point but the point is excluded (from either side) def empty?(%{left: {:exclusive, p}, right: {:exclusive, p}}), do: true @@ -1168,9 +1169,6 @@ defmodule Interval do defp point(:unbounded), do: nil defp point({_, point}), do: point - defp normalize_point!(_module, :empty), do: :empty - defp normalize_point!(_module, nil), do: nil - defp normalize_point!(module, point) do case module.point_normalize(point) do {:ok, point} -> point @@ -1178,8 +1176,6 @@ defmodule Interval do end end - defp normalize(%{left: :empty, right: :empty} = interval), do: interval - defp normalize(%module{} = interval) do case module.discrete?() do true -> normalize_discrete(interval) diff --git a/mix.exs b/mix.exs index 4f08d0a..b74248b 100644 --- a/mix.exs +++ b/mix.exs @@ -65,6 +65,7 @@ defmodule Interval.MixProject do defp test_coverage() do [ + ignore_modules: [Helper], summary: [threshold: 85] ] end diff --git a/test/builtin_test.exs b/test/builtin_test.exs index 8bac58d..e9569af 100644 --- a/test/builtin_test.exs +++ b/test/builtin_test.exs @@ -25,24 +25,79 @@ defmodule Interval.BuiltinTest do test "point_step/2" do assert ~D[2022-01-03] === DateInterval.point_step(~D[2022-01-01], 2) end + + test "point_normalize/1" do + assert {:ok, ~D[2022-01-01]} === DateInterval.point_normalize(~D[2022-01-01]) + assert :error === DateInterval.point_normalize(~U[2023-01-01 00:00:00Z]) + end + + test "point_compare/2" do + assert :eq === DateInterval.point_compare(~D[2022-01-01], ~D[2022-01-01]) + assert :lt === DateInterval.point_compare(~D[2022-01-01], ~D[2022-01-02]) + assert :gt === DateInterval.point_compare(~D[2022-01-02], ~D[2022-01-01]) + end end describe "Float" do test "point_step/2" do assert nil === FloatInterval.point_step(2.0, 2) end + + test "point_normalize/1" do + assert {:ok, 2.0} === FloatInterval.point_normalize(2.0) + assert {:ok, +0.0} === FloatInterval.point_normalize(0.0) + assert {:ok, +0.0} === FloatInterval.point_normalize(-0.0) + assert :error === FloatInterval.point_normalize(~D[2023-01-01]) + end end describe "DateTime" do test "point_step/2" do assert nil === DateTimeInterval.point_step(~U[2022-01-01 00:00:00Z], 2) end + + test "point_normalize/1" do + a = ~U[2022-01-01 00:00:00Z] + b = ~U[2022-01-01 00:00:00.000000Z] + + assert {:ok, a} === DateTimeInterval.point_normalize(a) + assert {:ok, b} === DateTimeInterval.point_normalize(b) + assert :error === DateTimeInterval.point_normalize(~D[2023-01-01]) + end + + test "point_compare/2" do + a = ~U[2022-01-01 00:00:00Z] + b = ~U[2022-01-01 00:00:01Z] + + assert :lt === DateTimeInterval.point_compare(a, b) + assert :eq === DateTimeInterval.point_compare(a, a) + assert :gt === DateTimeInterval.point_compare(b, a) + end end describe "NaiveDateTime" do test "point_step/2" do assert nil === NaiveDateTimeInterval.point_step(~N[2022-01-01 00:00:00], 2) end + + test "point_normalize/1" do + assert {:ok, ~N[2022-01-01 00:00:00]} === + NaiveDateTimeInterval.point_normalize(~N[2022-01-01 00:00:00]) + + assert {:ok, ~N[2022-01-01 00:00:00.000000]} === + NaiveDateTimeInterval.point_normalize(~N[2022-01-01 00:00:00.000000]) + + assert :error === NaiveDateTimeInterval.point_normalize(~D[2023-01-01]) + end + + test "point_compare/2" do + a = ~N[2022-01-01 00:00:00] + b = ~N[2022-01-01 00:00:01] + + assert :lt === NaiveDateTimeInterval.point_compare(a, b) + assert :eq === NaiveDateTimeInterval.point_compare(a, a) + assert :gt === NaiveDateTimeInterval.point_compare(b, a) + end end describe "Decimal" do @@ -53,5 +108,18 @@ defmodule Interval.BuiltinTest do test "discrete?/1" do refute DecimalInterval.discrete?() end + + test "point_normalize/1" do + assert {:ok, Decimal.new(2)} === DecimalInterval.point_normalize(Decimal.new(2)) + assert {:ok, Decimal.new(0)} === DecimalInterval.point_normalize(Decimal.new(0)) + assert {:ok, Decimal.new(0)} === DecimalInterval.point_normalize(Decimal.new(-0)) + assert :error === DecimalInterval.point_normalize(~D[2023-01-01]) + end + + test "point_compare/2" do + assert :lt === DecimalInterval.point_compare(Decimal.new(1), Decimal.new(2)) + assert :eq === DecimalInterval.point_compare(Decimal.new(2), Decimal.new(2)) + assert :gt === DecimalInterval.point_compare(Decimal.new(2), Decimal.new(1)) + end end end diff --git a/test/interval_test.exs b/test/interval_test.exs index 0d05e2e..4f9aedd 100644 --- a/test/interval_test.exs +++ b/test/interval_test.exs @@ -8,37 +8,59 @@ defmodule Interval.IntervalTest do alias DateIntervalInterval alias DateTimeIntervalInterval + defp inti(:empty), do: inti(:empty, :empty) defp inti(p), do: inti(p, p, "[]") defp inti(l, r), do: inti(l, r, nil) defp inti(l, r, bounds), do: IntegerInterval.new(l, r, bounds) - # defp floati(p), do: floati(p, p, "[]") + defp floati(:empty), do: floati(:empty, :empty) + defp floati(p), do: floati(p, p, "[]") defp floati(l, r), do: floati(l, r, nil) defp floati(l, r, bounds), do: FloatInterval.new(l, r, bounds) test "new/1" do # some normal construction - assert inti(1, 2) - assert inti(1, 3, "()") + assert IntegerInterval.new(1, 1) + assert IntegerInterval.new(1, 3, "()") + + assert IntegerInterval.new(empty: true) + assert IntegerInterval.new(left: :empty) + assert IntegerInterval.new(right: :empty) # unbounded specified by the bounds - assert ubr = inti(1, 1, "[") - assert ubl = inti(1, 1, "]") + assert ubr = IntegerInterval.new(1, 1, "[") + assert ubl = IntegerInterval.new(1, 1, "]") assert Interval.unbounded_left?(ubl) assert Interval.unbounded_right?(ubr) - assert inti(1, 1, "(") - assert inti(1, 1, ")") - assert inti(1, 1, "") + assert IntegerInterval.new(1, 1, "(") + assert IntegerInterval.new(1, 1, ")") + assert IntegerInterval.new(1, 1, "") - assert floati(1.0, 2.0) - assert floati(1.0, 3.0, "()") + assert FloatInterval.new(1.0, 2.0) + assert FloatInterval.new(1.0, 3.0, "()") # discrete type normalization - assert inti(1, 2) === - inti(1, 1, "[]") + assert IntegerInterval.new(1, 2) === + IntegerInterval.new(1, 1, "[]") + + assert IntegerInterval.new(1, 3, "()") === + IntegerInterval.new(2, 2, "[]") + + assert_raise ArgumentError, fn -> + IntegerInterval.new(1.0, 2.0, "[]") + end + end - assert inti(1, 3, "()") === - inti(2, 2, "[]") + test "value retrieval functions" do + a = inti(1, 2, "[)") + + assert 1 == Interval.left(a) + assert 2 == Interval.right(a) + + a = inti(nil, nil) + + assert nil == Interval.left(a) + assert nil == Interval.right(a) # unbounded left and right and both assert inti(1, nil) |> Interval.unbounded_right?() @@ -89,6 +111,9 @@ defmodule Interval.IntervalTest do } assert Interval.empty?(non_normalized_2) + + assert Interval.empty?(%IntegerInterval{left: :empty, right: :unbounded}) + assert Interval.empty?(%IntegerInterval{left: :unbounded, right: :empty}) end test "inclusive_left?/1" do @@ -130,12 +155,15 @@ defmodule Interval.IntervalTest do test "strictly_left_of?/2" do a = inti(1, 3, "[]") + refute Interval.strictly_left_of?(a, inti(nil, nil)) refute Interval.strictly_left_of?(a, inti(0)) refute Interval.strictly_left_of?(a, inti(1)) refute Interval.strictly_left_of?(a, inti(2)) refute Interval.strictly_left_of?(a, inti(3)) assert Interval.strictly_left_of?(a, inti(4)) assert Interval.strictly_left_of?(a, inti(4, 5)) + + refute Interval.strictly_left_of?(inti(nil, nil), inti(0)) end test "strictly_right_of?/2" do @@ -146,6 +174,9 @@ defmodule Interval.IntervalTest do refute Interval.strictly_right_of?(a, inti(2)) refute Interval.strictly_right_of?(a, inti(3)) refute Interval.strictly_right_of?(a, inti(4)) + refute Interval.strictly_right_of?(a, inti(nil, nil)) + + refute Interval.strictly_right_of?(inti(nil, nil), inti(0)) end test "adjacent_left_of?/2" do @@ -178,6 +209,24 @@ defmodule Interval.IntervalTest do end end + test "adjacent?/2" do + # integers + assert Interval.adjacent?(inti(1, 2), inti(2, 3)) + assert Interval.adjacent?(inti(1, 2, "[]"), inti(3, 4, "[]")) + refute Interval.adjacent?(inti(1, 2), inti(4, 5)) + refute Interval.adjacent?(inti(nil, nil), inti(1, 2)) + refute Interval.adjacent?(inti(1, 2), inti(nil, nil)) + + # floats + assert Interval.adjacent?(floati(1.0, 2.0), floati(2.0, 3.0)) + refute Interval.adjacent?(floati(1.0, 2.0), floati(3.0, 4.0)) + refute Interval.adjacent?(floati(1.0, 2.0), floati(4.0, 5.0)) + + refute Interval.adjacent?(floati(1.0, 2.0, "[]"), floati(2.0, 3.0, "[]")) + assert Interval.adjacent?(floati(1.0, 2.0, "[)"), floati(2.0, 3.0, "[]")) + assert Interval.adjacent?(floati(1.0, 2.0, "[]"), floati(2.0, 3.0, "(]")) + end + test "overlaps?/2" do # inclusive a = inti(1, 3, "[]") @@ -225,6 +274,14 @@ defmodule Interval.IntervalTest do refute Interval.overlaps?(floati(1.0, 1.0, "()"), floati(2.0, 2.0, "()")) end + test "union/2" do + assert Interval.union(inti(1, 3, "[]"), inti(2, 4, "[]")) === inti(1, 4, "[]") + assert Interval.union(inti(2, 4, "[]"), inti(1, 3, "[]")) === inti(1, 4, "[]") + + assert Interval.union(inti(:empty), inti(0, 3, "()")) === inti(0, 3, "()") + assert Interval.union(inti(0, 3, "()"), inti(:empty)) === inti(0, 3, "()") + end + test "intersection/2" do # intersection with empty is always empty (and we use the "empty" that was empty) assert Interval.intersection(inti(1, 10, "[]"), inti(3, 3, "()")) === inti(3, 3, "()") @@ -251,6 +308,13 @@ defmodule Interval.IntervalTest do assert Interval.intersection(floati(2.0, 3.0, "()"), floati(2.0, 3.0, "(]")) === floati(2.0, 3.0, "()") + + # one is empty + assert Interval.intersection(inti(1, 3, "[)"), inti(:empty)) === inti(:empty) + assert Interval.intersection(inti(:empty), inti(1, 3, "[)")) === inti(:empty) + + # disjoint + assert Interval.intersection(inti(1, 3, "[)"), inti(4, 5, "[)")) === inti(:empty) end test "intersection/2 with unbounded intervals" do @@ -338,6 +402,44 @@ defmodule Interval.IntervalTest do assert Interval.compare_bounds(:right, a, :right, b) === :gt assert Interval.compare_bounds(:left, a, :left, b) === :gt assert Interval.compare_bounds(:left, a, :right, b) === :eq + + a = floati(0.0, 1.0, "()") + b = floati(1.0, 2.0, "()") + assert Interval.compare_bounds(:right, a, :left, b) === :lt + assert Interval.compare_bounds(:right, a, :right, b) === :lt + assert Interval.compare_bounds(:left, a, :left, b) === :lt + assert Interval.compare_bounds(:left, a, :right, b) === :lt + + a = floati(1.0, 2.0, "()") + b = floati(0.0, 1.0, "()") + assert Interval.compare_bounds(:right, a, :left, b) === :gt + assert Interval.compare_bounds(:right, a, :right, b) === :gt + assert Interval.compare_bounds(:left, a, :left, b) === :gt + assert Interval.compare_bounds(:left, a, :right, b) === :gt + end + + test "compare_bounds/4 - unboundeded and empty endpoints" do + assert_raise Interval.IntervalOperationError, fn -> + Interval.compare_bounds(:left, floati(:empty), :left, floati(0.0, 1.0, "[]")) + end + + assert_raise Interval.IntervalOperationError, fn -> + Interval.compare_bounds(:left, floati(0.0, 1.0, "[]"), :left, floati(:empty)) + end + + a = floati(nil, 1.0) + b = floati(nil, 2.0) + assert Interval.compare_bounds(:left, a, :left, b) === :eq + assert Interval.compare_bounds(:left, a, :right, b) === :lt + assert Interval.compare_bounds(:right, a, :right, b) === :lt + assert Interval.compare_bounds(:right, a, :left, b) === :gt + + a = floati(nil, nil) + b = floati(nil, nil) + assert Interval.compare_bounds(:left, a, :left, b) === :eq + assert Interval.compare_bounds(:left, a, :right, b) === :lt + assert Interval.compare_bounds(:right, a, :right, b) === :eq + assert Interval.compare_bounds(:right, a, :left, b) === :gt end test "contains_point?/2" do @@ -393,6 +495,18 @@ defmodule Interval.IntervalTest do assert Interval.partition(inti(1, 4), 3) === [inti(1, 3), inti(3), inti(0, 0)] assert Interval.partition(inti(1, 4), 0) === [] assert Interval.partition(inti(1, 4), 4) === [] + + assert Interval.partition(floati(1.0, 3.0, "[)"), 2.0) === [ + floati(1.0, 2.0, "[)"), + floati(2.0, 2.0, "[]"), + floati(2.0, 3.0, "()") + ] + + assert Interval.partition(floati(nil, nil, "[]"), 2.0) === [ + floati(nil, 2.0, "[)"), + floati(2.0, 2.0, "[]"), + floati(2.0, nil, "()") + ] end test "partition/2's result unioned together is it's input interval" do