From 420620d7dba241f06252b38b92ccefa1da6ad4ef Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Thu, 22 Jan 2026 16:57:46 -0800 Subject: [PATCH 1/7] Update DateScale and DateTimeScale to handle start and step options + Add docs --- lib/plox/date_scale.ex | 37 +++++++++++++++++++++++++++++-------- lib/plox/date_time_scale.ex | 25 ++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/lib/plox/date_scale.ex b/lib/plox/date_scale.ex index e1334d3..597048a 100644 --- a/lib/plox/date_scale.ex +++ b/lib/plox/date_scale.ex @@ -4,7 +4,8 @@ defmodule Plox.DateScale do This struct implements the `Plox.Scale` protocol. - `Plox.Scale.values/2` returns a `t:Date.Range.t/0` enumerable: + `Plox.Scale.values/2` returns a `t:Date.Range.t/0` enumerable and accepts + `step` and `start` options: iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-10], 1)) iex> scale |> Plox.Scale.values(%{step: 2}) |> Enum.to_list() @@ -14,6 +15,10 @@ defmodule Plox.DateScale do iex> scale |> Plox.Scale.values(%{step: 3}) |> Enum.to_list() [~D[2020-01-10], ~D[2020-01-07], ~D[2020-01-04], ~D[2020-01-01]] + iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-10], 1)) + iex> scale |> Plox.Scale.values(%{start: ~D[2020-01-07]}) |> Enum.to_list() + [~D[2020-01-07], ~D[2020-01-08], ~D[2020-01-09], ~D[2020-01-10]] + `Plox.Scale.convert_to_range/3` returns a number in the given range: iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-09], 1)) @@ -67,15 +72,31 @@ defmodule Plox.DateScale do Returns a `t:Date.Range.t/0` of all `t:Date.t/0` values in the scale, stepping by the given interval. - Accepts a `step` option as a number of days. The default step is 1 day. - Raises if given `step` is not a positive integer greater than 1. + Maintains the direction of the original range (positive or negative). + + ## Options + + * `:step` - The number of days between each value. Must be a positive integer. + Defaults to `1`. + * `:start` - The starting date for generating values. Must be a `t:Date.t/0` + within the scale's domain. Defaults to the first date in the scale's range. """ def values(scale, opts) do - case Map.fetch(opts, :step) do - :error -> scale.range - {:ok, step} when step < 1 -> raise ArgumentError, message: "Step must be a positive integer" - {:ok, step} when scale.range.step > 0 -> %{scale.range | step: step} - {:ok, step} when scale.range.step < 0 -> %{scale.range | step: -step} + first_value = Map.get(opts, :start, scale.range.first) + step = Map.get(opts, :step, 1) + + unless first_value in scale.range do + raise ArgumentError, message: "DateScale: start value must be within the range of the scale" + end + + if step < 1 do + raise ArgumentError, message: "DateScale: step must be a positive integer" + end + + if scale.range.step > 0 do + Date.range(first_value, scale.range.last, step) + else + Date.range(first_value, scale.range.last, -step) end end diff --git a/lib/plox/date_time_scale.ex b/lib/plox/date_time_scale.ex index 5ca70d4..86fb5ae 100644 --- a/lib/plox/date_time_scale.ex +++ b/lib/plox/date_time_scale.ex @@ -4,7 +4,8 @@ defmodule Plox.DateTimeScale do This struct implements the `Plox.Scale` protocol. - `Plox.Scale.values/2` returns a list of all datetime values: + `Plox.Scale.values/2` returns a list of all datetime values and accepts + `step` and `start` options: iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:03:00]) iex> Plox.Scale.values(scale) @@ -14,6 +15,10 @@ defmodule Plox.DateTimeScale do iex> Plox.Scale.values(scale, %{step: {1, :day}}) [~U[2019-01-01 00:00:00Z], ~U[2019-01-02 00:00:00Z], ~U[2019-01-03 00:00:00Z]] + iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:03:00]) + iex> Plox.Scale.values(scale, %{start: ~N[2019-01-01 00:01:00]}) + [~N[2019-01-01 00:01:00], ~N[2019-01-01 00:02:00], ~N[2019-01-01 00:03:00]] + `Plox.Scale.convert_to_range/3` returns a number in the given range: iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) @@ -64,8 +69,14 @@ defmodule Plox.DateTimeScale do Returns a list of all `DateTime` or `NaiveDateTime` values in the scale, stepping by the given interval. - Accepts a `:step` option, which can be a number of seconds, minutes, hours, - or days. The default step is 60 seconds. + ## Options + + * `:step` - The step interval. Can be an integer number of seconds, + or a tuple of `{integer, :second | :minute | :hour | :day}`. + Defaults to `{60, :second}`. + + * `:start` - The starting datetime value. Must be included in the scale. + Defaults to the first value in the scale. """ def values(%{first: %DateTime{time_zone: tz}} = scale, %{step: {step_days, :day}}) when tz != "Etc/UTC" do scale.first @@ -87,6 +98,9 @@ defmodule Plox.DateTimeScale do {minutes, :minute} -> minutes * 60 {hours, :hour} -> hours * 3600 {days, :day} -> days * 86_400 + _ -> + raise ArgumentError, + message: "DateTimeScale: step must be an integer or a {integer, :second | :minute | :hour | :day} tuple" end if date_time_module == DateTime and scale.first.time_zone != "Etc/UTC" and @@ -98,6 +112,11 @@ defmodule Plox.DateTimeScale do first_value = Map.get(opts, :start, scale.first) + unless date_time_module.compare(first_value, scale.first) != :lt and + date_time_module.compare(first_value, scale.last) != :gt do + raise ArgumentError, message: "DateTimeScale: start value must be within the range of the scale" + end + total_seconds = date_time_module.diff(scale.last, first_value) ticks = trunc(total_seconds / step_seconds) From 3e280cb61ad2751406c5a5fd0d9a394cca2987cb Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Thu, 22 Jan 2026 17:05:50 -0800 Subject: [PATCH 2/7] Add tests for DateScale and DateTimeScale start/step options --- lib/plox/date_time_scale.ex | 25 ++++++++++++----- test/plox/date_scale_test.exs | 20 +++++++++++++- test/plox/date_time_scale_test.exs | 43 ++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/lib/plox/date_time_scale.ex b/lib/plox/date_time_scale.ex index 86fb5ae..1d57db5 100644 --- a/lib/plox/date_time_scale.ex +++ b/lib/plox/date_time_scale.ex @@ -71,7 +71,7 @@ defmodule Plox.DateTimeScale do ## Options - * `:step` - The step interval. Can be an integer number of seconds, + * `:step` - The step interval. Can be a non-zero integer number of seconds, or a tuple of `{integer, :second | :minute | :hour | :day}`. Defaults to `{60, :second}`. @@ -93,14 +93,25 @@ defmodule Plox.DateTimeScale do def values(%{first: %date_time_module{}} = scale, opts) do step_seconds = case Map.get(opts, :step, {60, :second}) do - seconds when is_integer(seconds) -> seconds - {seconds, :second} -> seconds - {minutes, :minute} -> minutes * 60 - {hours, :hour} -> hours * 3600 - {days, :day} -> days * 86_400 + seconds when is_integer(seconds) and seconds > 0 -> + seconds + + {seconds, :second} -> + seconds + + {minutes, :minute} -> + minutes * 60 + + {hours, :hour} -> + hours * 3600 + + {days, :day} -> + days * 86_400 + _ -> raise ArgumentError, - message: "DateTimeScale: step must be an integer or a {integer, :second | :minute | :hour | :day} tuple" + message: + "DateTimeScale: step must be a non-zero integer or a {integer, :second | :minute | :hour | :day} tuple" end if date_time_module == DateTime and scale.first.time_zone != "Etc/UTC" and diff --git a/test/plox/date_scale_test.exs b/test/plox/date_scale_test.exs index 708b19b..9afe2fa 100644 --- a/test/plox/date_scale_test.exs +++ b/test/plox/date_scale_test.exs @@ -38,7 +38,7 @@ defmodule Plox.DateScaleTest do describe "values/2" do test "with default step (1d)" do scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-03])) - assert %Date.Range{first: ~D[2019-01-01], last: ~D[2019-01-03], step: 1} = Scale.values(scale) + assert Scale.values(scale) == Date.range(~D[2019-01-01], ~D[2019-01-03], 1) end test "with positive custom step in days" do @@ -58,6 +58,24 @@ defmodule Plox.DateScaleTest do Scale.values(scale, %{step: 0}) end end + + test "with default start" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) + assert Scale.values(scale) == Date.range(~D[2019-01-01], ~D[2019-01-05]) + end + + test "with custom start" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) + assert Scale.values(scale, %{start: ~D[2019-01-03]}) == Date.range(~D[2019-01-03], ~D[2019-01-05]) + end + + test "raises an error with start outside range" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: ~D[2018-12-31]}) + end + end end describe "convert_to_range/3" do diff --git a/test/plox/date_time_scale_test.exs b/test/plox/date_time_scale_test.exs index 81c33f1..c6bd5a2 100644 --- a/test/plox/date_time_scale_test.exs +++ b/test/plox/date_time_scale_test.exs @@ -94,6 +94,49 @@ defmodule Plox.DateTimeScaleTest do ~N[2019-01-01 00:00:03] ] end + + test "raises an error with zero step" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{step: 0}) + end + end + + test "raises an error with invalid step" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{step: {-500, :invalid}}) + end + end + + test "with default start" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert Scale.values(scale) == [ + ~N[2019-01-01 00:00:00], + ~N[2019-01-01 00:01:00], + ~N[2019-01-01 00:02:00] + ] + end + + test "with custom start" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert Scale.values(scale, %{start: ~N[2019-01-01 00:01:00]}) == [ + ~N[2019-01-01 00:01:00], + ~N[2019-01-01 00:02:00] + ] + end + + test "raises an error with start outside range" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: ~N[2019-01-01 00:03:00]}) + end + end end describe "convert_to_range/3" do From fe16375fd09d91e31948fe06c14c71317e619f61 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Fri, 23 Jan 2026 11:36:00 -0800 Subject: [PATCH 3/7] fixup! Update DateScale and DateTimeScale to handle start and step options + Add docs --- lib/plox/date_time_scale.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/plox/date_time_scale.ex b/lib/plox/date_time_scale.ex index 1d57db5..5be4b9f 100644 --- a/lib/plox/date_time_scale.ex +++ b/lib/plox/date_time_scale.ex @@ -123,8 +123,8 @@ defmodule Plox.DateTimeScale do first_value = Map.get(opts, :start, scale.first) - unless date_time_module.compare(first_value, scale.first) != :lt and - date_time_module.compare(first_value, scale.last) != :gt do + if date_time_module.compare(first_value, scale.first) == :lt or + date_time_module.compare(first_value, scale.last) == :gt do raise ArgumentError, message: "DateTimeScale: start value must be within the range of the scale" end From 094df1f23d87efcee0e97f2a9fc0069214eb48b3 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Fri, 23 Jan 2026 11:42:05 -0800 Subject: [PATCH 4/7] Explicitly test start values before/after range --- test/plox/date_scale_test.exs | 10 +++++++++- test/plox/date_time_scale_test.exs | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/test/plox/date_scale_test.exs b/test/plox/date_scale_test.exs index 9afe2fa..595bec2 100644 --- a/test/plox/date_scale_test.exs +++ b/test/plox/date_scale_test.exs @@ -69,13 +69,21 @@ defmodule Plox.DateScaleTest do assert Scale.values(scale, %{start: ~D[2019-01-03]}) == Date.range(~D[2019-01-03], ~D[2019-01-05]) end - test "raises an error with start outside range" do + test "raises an error with start before range" do scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) assert_raise ArgumentError, fn -> Scale.values(scale, %{start: ~D[2018-12-31]}) end end + + test "raises an error with start after range" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: ~D[2019-01-06]}) + end + end end describe "convert_to_range/3" do diff --git a/test/plox/date_time_scale_test.exs b/test/plox/date_time_scale_test.exs index c6bd5a2..8ff6361 100644 --- a/test/plox/date_time_scale_test.exs +++ b/test/plox/date_time_scale_test.exs @@ -130,7 +130,15 @@ defmodule Plox.DateTimeScaleTest do ] end - test "raises an error with start outside range" do + test "raises an error with start before range" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: ~N[2018-12-31 23:59:59]}) + end + end + + test "raises an error with start after range" do scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) assert_raise ArgumentError, fn -> From f100cc2aff8c6a46ca512ace872d9583a7b13f1c Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Fri, 23 Jan 2026 13:43:48 -0800 Subject: [PATCH 5/7] DateScale and DateTimeScale fixups + Add edge case tests --- lib/plox/date_time_scale.ex | 16 ++++++++-------- test/plox/date_scale_test.exs | 15 ++++++++++++++- test/plox/date_time_scale_test.exs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/lib/plox/date_time_scale.ex b/lib/plox/date_time_scale.ex index 5be4b9f..77e1558 100644 --- a/lib/plox/date_time_scale.ex +++ b/lib/plox/date_time_scale.ex @@ -91,6 +91,13 @@ defmodule Plox.DateTimeScale do end def values(%{first: %date_time_module{}} = scale, opts) do + first_value = Map.get(opts, :start, scale.first) + + if date_time_module.compare(first_value, scale.first) == :lt or + date_time_module.compare(first_value, scale.last) == :gt do + raise ArgumentError, message: "DateTimeScale: start value must be within the range of the scale" + end + step_seconds = case Map.get(opts, :step, {60, :second}) do seconds when is_integer(seconds) and seconds > 0 -> @@ -117,17 +124,10 @@ defmodule Plox.DateTimeScale do if date_time_module == DateTime and scale.first.time_zone != "Etc/UTC" and step_seconds > 3600 do Logger.warning(fn -> - "DateTimeScale: steps greater than an hour in seconds for non UTC DateTimes are not safe to use because of DST shifts" + "DateTimeScale: steps greater than an hour for non UTC DateTimes are not safe to use because of DST shifts" end) end - first_value = Map.get(opts, :start, scale.first) - - if date_time_module.compare(first_value, scale.first) == :lt or - date_time_module.compare(first_value, scale.last) == :gt do - raise ArgumentError, message: "DateTimeScale: start value must be within the range of the scale" - end - total_seconds = date_time_module.diff(scale.last, first_value) ticks = trunc(total_seconds / step_seconds) diff --git a/test/plox/date_scale_test.exs b/test/plox/date_scale_test.exs index 595bec2..08ac9cd 100644 --- a/test/plox/date_scale_test.exs +++ b/test/plox/date_scale_test.exs @@ -51,7 +51,7 @@ defmodule Plox.DateScaleTest do assert Scale.values(scale, %{step: 2}) == Date.range(~D[2019-01-10], ~D[2019-01-01], -2) end - test "raises an error with invalid step" do + test "raises an error with zero step" do scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10])) assert_raise ArgumentError, fn -> @@ -59,6 +59,14 @@ defmodule Plox.DateScaleTest do end end + test "raises an error with negative step" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10])) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{step: -1}) + end + end + test "with default start" do scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) assert Scale.values(scale) == Date.range(~D[2019-01-01], ~D[2019-01-05]) @@ -84,6 +92,11 @@ defmodule Plox.DateScaleTest do Scale.values(scale, %{start: ~D[2019-01-06]}) end end + + test "with custom step and start" do + scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10])) + assert Scale.values(scale, %{step: 3, start: ~D[2019-01-02]}) == Date.range(~D[2019-01-02], ~D[2019-01-10], 3) + end end describe "convert_to_range/3" do diff --git a/test/plox/date_time_scale_test.exs b/test/plox/date_time_scale_test.exs index 8ff6361..ba9a729 100644 --- a/test/plox/date_time_scale_test.exs +++ b/test/plox/date_time_scale_test.exs @@ -55,6 +55,16 @@ defmodule Plox.DateTimeScaleTest do ] end + test "with valid step in total seconds" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert Scale.values(scale, %{step: 60}) == [ + ~N[2019-01-01 00:00:00], + ~N[2019-01-01 00:01:00], + ~N[2019-01-01 00:02:00] + ] + end + test "with valid step in days" do scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00]) @@ -103,6 +113,14 @@ defmodule Plox.DateTimeScaleTest do end end + test "raises an error with negative step" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{step: -60}) + end + end + test "raises an error with invalid step" do scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) @@ -145,6 +163,18 @@ defmodule Plox.DateTimeScaleTest do Scale.values(scale, %{start: ~N[2019-01-01 00:03:00]}) end end + + test "with custom step and start" do + scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:10:00]) + + assert Scale.values(scale, %{step: {2, :minute}, start: ~N[2019-01-01 00:01:00]}) == [ + ~N[2019-01-01 00:01:00], + ~N[2019-01-01 00:03:00], + ~N[2019-01-01 00:05:00], + ~N[2019-01-01 00:07:00], + ~N[2019-01-01 00:09:00] + ] + end end describe "convert_to_range/3" do From 6e1ecd39ec0494de5fbf70733f1349878b61eab5 Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Tue, 27 Jan 2026 15:52:08 -0800 Subject: [PATCH 6/7] fixup! DateScale and DateTimeScale fixups + Add edge case tests --- lib/plox/date_scale.ex | 5 +++-- lib/plox/date_time_scale.ex | 6 +++--- test/plox/date_scale_test.exs | 7 +------ test/plox/date_time_scale_test.exs | 12 +----------- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/lib/plox/date_scale.ex b/lib/plox/date_scale.ex index 597048a..5c268a4 100644 --- a/lib/plox/date_scale.ex +++ b/lib/plox/date_scale.ex @@ -76,10 +76,11 @@ defmodule Plox.DateScale do ## Options - * `:step` - The number of days between each value. Must be a positive integer. - Defaults to `1`. * `:start` - The starting date for generating values. Must be a `t:Date.t/0` within the scale's domain. Defaults to the first date in the scale's range. + + * `:step` - The number of days between each value. Must be a positive integer. + Defaults to `1`. """ def values(scale, opts) do first_value = Map.get(opts, :start, scale.range.first) diff --git a/lib/plox/date_time_scale.ex b/lib/plox/date_time_scale.ex index 77e1558..6d7d5ee 100644 --- a/lib/plox/date_time_scale.ex +++ b/lib/plox/date_time_scale.ex @@ -71,12 +71,12 @@ defmodule Plox.DateTimeScale do ## Options + * `:start` - The starting datetime value. Must be included in the scale. + Defaults to the first value in the scale. + * `:step` - The step interval. Can be a non-zero integer number of seconds, or a tuple of `{integer, :second | :minute | :hour | :day}`. Defaults to `{60, :second}`. - - * `:start` - The starting datetime value. Must be included in the scale. - Defaults to the first value in the scale. """ def values(%{first: %DateTime{time_zone: tz}} = scale, %{step: {step_days, :day}}) when tz != "Etc/UTC" do scale.first diff --git a/test/plox/date_scale_test.exs b/test/plox/date_scale_test.exs index 08ac9cd..40d4ed4 100644 --- a/test/plox/date_scale_test.exs +++ b/test/plox/date_scale_test.exs @@ -36,7 +36,7 @@ defmodule Plox.DateScaleTest do end describe "values/2" do - test "with default step (1d)" do + test "with default step (1d) and start" do scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-03])) assert Scale.values(scale) == Date.range(~D[2019-01-01], ~D[2019-01-03], 1) end @@ -67,11 +67,6 @@ defmodule Plox.DateScaleTest do end end - test "with default start" do - scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) - assert Scale.values(scale) == Date.range(~D[2019-01-01], ~D[2019-01-05]) - end - test "with custom start" do scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05])) assert Scale.values(scale, %{start: ~D[2019-01-03]}) == Date.range(~D[2019-01-03], ~D[2019-01-05]) diff --git a/test/plox/date_time_scale_test.exs b/test/plox/date_time_scale_test.exs index ba9a729..50d731b 100644 --- a/test/plox/date_time_scale_test.exs +++ b/test/plox/date_time_scale_test.exs @@ -45,7 +45,7 @@ defmodule Plox.DateTimeScaleTest do end describe "values/2" do - test "with default step (60s)" do + test "with default step (60s) and start" do scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) assert Scale.values(scale) == [ @@ -129,16 +129,6 @@ defmodule Plox.DateTimeScaleTest do end end - test "with default start" do - scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) - - assert Scale.values(scale) == [ - ~N[2019-01-01 00:00:00], - ~N[2019-01-01 00:01:00], - ~N[2019-01-01 00:02:00] - ] - end - test "with custom start" do scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00]) From 1723dae5e7ec6db6cb8338ec0ef62a3e269b889f Mon Sep 17 00:00:00 2001 From: Nikki Kyllonen Date: Tue, 27 Jan 2026 15:53:54 -0800 Subject: [PATCH 7/7] Add start option to NumberScale + Update tests --- lib/plox/number_scale.ex | 39 +++++++++++++++++++++++++++----- test/plox/number_scale_test.exs | 40 ++++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/lib/plox/number_scale.ex b/lib/plox/number_scale.ex index 6f20202..b94b6af 100644 --- a/lib/plox/number_scale.ex +++ b/lib/plox/number_scale.ex @@ -8,7 +8,8 @@ defmodule Plox.NumberScale do This struct implements the `Plox.Scale` protocol. - `Plox.Scale.values/2` returns an enumerable of the numerical values in the scale: + `Plox.Scale.values/2` returns an enumerable of the numerical values in the scale + and accepts `start` and `ticks` options: iex> scale = Plox.NumberScale.new(0, 10) iex> Plox.Scale.values(scale) @@ -18,6 +19,10 @@ defmodule Plox.NumberScale do iex> Plox.Scale.values(scale, %{ticks: 6}) [10.0, 8.0, 6.0, 4.0, 2.0, 0.0] + iex> scale = Plox.NumberScale.new(0, 10) + iex> Plox.Scale.values(scale, %{start: 5}) + [5.0, 5.5, 6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0] + `Plox.Scale.convert_to_range/3` returns a number in the given range: iex> scale = Plox.NumberScale.new(0, 10) @@ -69,20 +74,44 @@ defmodule Plox.NumberScale do dynamically calculated based on `first`, `last`, and `ticks`. Raises if `ticks` is less than `2` (default is `11`). + + ## Options + + * `:start` - The starting value. Must be a number within the scale's domain. + Defaults to the first value in the scale. + + * `:ticks` - The number of scale values to return. Must be at least `2`. + Defaults to `11`. """ def values(scale, opts) do + first_value = + case Map.get(opts, :start) do + nil -> scale.first + value when is_number(value) -> Decimal.from_float(value / 1.0) + value -> raise ArgumentError, message: "NumberScale: start must be a number, got #{inspect(value)}" + end + + minimum = if scale.backwards?, do: scale.last, else: scale.first + maximum = if scale.backwards?, do: scale.first, else: scale.last + + if Decimal.lt?(first_value, minimum) or Decimal.gt?(first_value, maximum) do + raise ArgumentError, + message: "NumberScale: start value #{Decimal.to_float(first_value)} must be within the scale range" + end + ticks = Map.get(opts, :ticks, 11) - if ticks < 2 do - raise ArgumentError, message: "Invalid ticks count `#{ticks}`, must be at least 2" + case ticks do + n when is_integer(n) and n >= 2 -> n + _ -> raise ArgumentError, message: "NumberScale: ticks must be an integer >= 2" end - step = scale.last |> Decimal.sub(scale.first) |> Decimal.div(ticks - 1) + step = scale.last |> Decimal.sub(first_value) |> Decimal.div(ticks - 1) # we don't compute the last value because it could include rounding errors # carried through each step, instead we just append `scale.last` 0..(ticks - 2) - |> Enum.map_reduce(scale.first, fn _i, acc -> {acc, Decimal.add(acc, step)} end) + |> Enum.map_reduce(first_value, fn _i, acc -> {acc, Decimal.add(acc, step)} end) |> elem(0) |> Kernel.++([scale.last]) |> Enum.map(&Decimal.to_float/1) diff --git a/test/plox/number_scale_test.exs b/test/plox/number_scale_test.exs index 9b08f72..563ee01 100644 --- a/test/plox/number_scale_test.exs +++ b/test/plox/number_scale_test.exs @@ -29,7 +29,7 @@ defmodule Plox.NumberScaleTest do end describe "values/2" do - test "with default ticks" do + test "with default ticks (11) and start" do scale = NumberScale.new(0, 10) assert Scale.values(scale) == [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] end @@ -39,18 +39,52 @@ defmodule Plox.NumberScaleTest do assert Scale.values(scale, %{ticks: 5}) == [0.0, 2.5, 5.0, 7.5, 10.0] end - test "with reversed scale" do + test "with default ticks (11) and reversed scale" do scale = NumberScale.new(10, 0) assert Scale.values(scale) == [10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0, 0.0] end - test "raises an error with invalid ticks" do + test "raises an error with ticks < 2" do scale = NumberScale.new(0, 10) assert_raise ArgumentError, fn -> Scale.values(scale, %{ticks: 1}) end end + + test "raises an error with non-integer ticks" do + scale = NumberScale.new(0, 10) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{ticks: 5.5}) + end + end + + test "with custom start" do + scale = NumberScale.new(0, 10) + assert Scale.values(scale, %{start: 5}) == [5.0, 5.5, 6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0] + end + + test "with custom start and ticks" do + scale = NumberScale.new(0, 10) + assert Scale.values(scale, %{start: 2, ticks: 5}) == [2.0, 4.0, 6.0, 8.0, 10.0] + end + + test "raises an error with start before range" do + scale = NumberScale.new(0, 10) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: -1}) + end + end + + test "raises an error with start after range" do + scale = NumberScale.new(0, 10) + + assert_raise ArgumentError, fn -> + Scale.values(scale, %{start: 11}) + end + end end describe "convert_to_range/3" do