From 177282b305ff1d8155996596de3e2366029f44ed Mon Sep 17 00:00:00 2001 From: Anthony Shull Date: Tue, 11 Feb 2025 10:46:34 -0600 Subject: [PATCH] DateTime util module and tests (#2372) * date_time module and tests * docs * linting * some cleanup * some cleanup * remove broken tests and add new tests * this week test * make service rollover time configurable * split into two modules * more tests * 100% coverage * some docs * some docs * format * helper funcs * move test to helper func * docs * format * move private funcs to factory * format * alphabetize config * remove unused import * service range and tests * PR feedback * microsecond fidelity * microsecond fidelity * nil in range and docs * more tests * docs * move in_range? * call it date time range --- config/config.exs | 38 ++-- config/test.exs | 3 + lib/dotcom/utils/date_time.ex | 75 +++++++ lib/dotcom/utils/date_time/behaviour.ex | 19 ++ lib/dotcom/utils/service_date_time.ex | 209 ++++++++++++++++++ mix.exs | 1 + mix.lock | 1 + test/dotcom/utils/date_time_test.exs | 113 ++++++++++ test/dotcom/utils/service_date_time_test.exs | 221 +++++++++++++++++++ test/support/generators/date_time.ex | 44 ++++ test/support/generators/service_date_time.ex | 31 +++ test/support/mocks.ex | 1 + 12 files changed, 741 insertions(+), 15 deletions(-) create mode 100644 lib/dotcom/utils/date_time.ex create mode 100644 lib/dotcom/utils/date_time/behaviour.ex create mode 100644 lib/dotcom/utils/service_date_time.ex create mode 100644 test/dotcom/utils/date_time_test.exs create mode 100644 test/dotcom/utils/service_date_time_test.exs create mode 100644 test/support/generators/date_time.ex create mode 100644 test/support/generators/service_date_time.ex diff --git a/config/config.exs b/config/config.exs index 393e383236..fb6b00fbc1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,23 +1,20 @@ import Config -config :elixir, ansi_enabled: true - config :dotcom, :aws_client, AwsClient.Behaviour -config :dotcom, :content_security_policy_definition, "" config :dotcom, :cms_api_module, CMS.Api -config :dotcom, :httpoison, HTTPoison +config :dotcom, :content_security_policy_definition, "" -config :dotcom, :mbta_api_module, MBTA.Api +config :dotcom, :date_time_module, Dotcom.Utils.DateTime + +config :dotcom, :httpoison, HTTPoison config :dotcom, :location_service, LocationService -config :dotcom, :repo_modules, - predictions: Predictions.Repo, - route_patterns: RoutePatterns.Repo, - routes: Routes.Repo, - stops: Stops.Repo +config :dotcom, :mbta_api_module, MBTA.Api + +config :dotcom, :otp_module, OpenTripPlannerClient config :dotcom, :predictions_phoenix_pub_sub, Predictions.Phoenix.PubSub config :dotcom, :predictions_pub_sub, Predictions.PubSub @@ -27,9 +24,18 @@ config :dotcom, :redis, Dotcom.Cache.Multilevel.Redis config :dotcom, :redix, Redix config :dotcom, :redix_pub_sub, Redix.PubSub -config :dotcom, :otp_module, OpenTripPlannerClient +config :dotcom, :repo_modules, + predictions: Predictions.Repo, + route_patterns: RoutePatterns.Repo, + routes: Routes.Repo, + stops: Stops.Repo + config :dotcom, :req_module, Req +config :dotcom, :service_rollover_time, ~T[03:00:00] + +config :dotcom, :timezone, "America/New_York" + tile_server_url = if config_env() == :prod, do: "https://cdn.mbta.com", @@ -37,10 +43,7 @@ tile_server_url = config :dotcom, tile_server_url: tile_server_url -config :sentry, - enable_source_code_context: true, - root_source_code_paths: [File.cwd!()], - context_lines: 5 +config :elixir, ansi_enabled: true config :mbta_metro, custom_icons: ["#{File.cwd!()}/priv/static/icon-svg/*"] @@ -74,4 +77,9 @@ config :mbta_metro, :map, %{ zoom: 14 } +config :sentry, + enable_source_code_context: true, + root_source_code_paths: [File.cwd!()], + context_lines: 5 + import_config "#{config_env()}.exs" diff --git a/config/test.exs b/config/test.exs index fb422136af..71130b86fc 100644 --- a/config/test.exs +++ b/config/test.exs @@ -12,6 +12,9 @@ config :dotcom, :cache, Dotcom.Cache.TestCache config :dotcom, :httpoison, HTTPoison.Mock config :dotcom, :cms_api_module, CMS.Api.Static + +config :dotcom, :date_time_module, Dotcom.Utils.DateTime.Mock + config :dotcom, :mbta_api_module, MBTA.Api.Mock config :dotcom, :location_service, LocationService.Mock diff --git a/lib/dotcom/utils/date_time.ex b/lib/dotcom/utils/date_time.ex new file mode 100644 index 0000000000..6703450fd7 --- /dev/null +++ b/lib/dotcom/utils/date_time.ex @@ -0,0 +1,75 @@ +defmodule Dotcom.Utils.DateTime do + @moduledoc """ + A collection of functions for working with date_times. + + Consuming modules are responsible for parsing or converting date_times. + They should *always* call `coerce_ambiguous_date_time/1` before using a date_time. + This is mainly because Timex has so many functions that serve as entry points to date_times. + Those functions can return ambiguous date_times during DST transitions. + """ + + use Timex + + alias Dotcom.Utils.DateTime.Behaviour + + @behaviour Behaviour + + @typedoc """ + A date_time_range is a tuple of two date_times: {start, stop}. + Either the start or stop can be nil, but not both. + """ + @type date_time_range() :: + {DateTime.t(), DateTime.t()} | {nil, DateTime.t()} | {DateTime.t(), nil} + + @timezone Application.compile_env!(:dotcom, :timezone) + + @doc """ + Get the date_time in the set @timezone. + """ + @impl Behaviour + def now(), do: Timex.now(@timezone) + + @doc """ + In the default case, we'll return a DateTime when given one. + + Timex can give us ambiguous times when we "fall-back" in DST transitions. + That is because the same hour occurs twice. + In that case, we choose the later time. + + Timex will return an error if the time occurs when we "spring-forward" in DST transitions. + That is because one hour does not occur--02:00:00am to 03:00:00am. + In that case, we set the time to 03:00:00am. + """ + @impl Behaviour + def coerce_ambiguous_date_time(%DateTime{} = date_time), do: date_time + def coerce_ambiguous_date_time(%Timex.AmbiguousDateTime{after: later}), do: later + + def coerce_ambiguous_date_time({:error, {_, @timezone, seconds_from_zeroyear, _}}) do + Timex.zero() + |> Timex.shift(seconds: seconds_from_zeroyear) + |> Timex.to_datetime(@timezone) + |> coerce_ambiguous_date_time() + |> Timex.shift(hours: 2) + |> coerce_ambiguous_date_time() + end + + @doc """ + Given a date_time_range and a date_time, returns true if the date_time is within the date_time_range. + """ + @impl Behaviour + def in_range?({nil, nil}, _), do: false + + def in_range?({nil, %DateTime{} = stop}, %DateTime{} = date_time) do + Timex.before?(date_time, stop) || Timex.equal?(date_time, stop, :microsecond) + end + + def in_range?({%DateTime{} = start, nil}, %DateTime{} = date_time) do + Timex.after?(date_time, start) || Timex.equal?(date_time, start, :microsecond) + end + + def in_range?({%DateTime{} = start, %DateTime{} = stop}, %DateTime{} = date_time) do + in_range?({start, nil}, date_time) && in_range?({nil, stop}, date_time) + end + + def in_range?(_, _), do: false +end diff --git a/lib/dotcom/utils/date_time/behaviour.ex b/lib/dotcom/utils/date_time/behaviour.ex new file mode 100644 index 0000000000..cc05b6004b --- /dev/null +++ b/lib/dotcom/utils/date_time/behaviour.ex @@ -0,0 +1,19 @@ +defmodule Dotcom.Utils.DateTime.Behaviour do + @moduledoc """ + A behaviour for working with date_times. + """ + + @callback now() :: DateTime.t() + + @callback coerce_ambiguous_date_time( + DateTime.t() + | Timex.AmbiguousDateTime.t() + | {:error, term()} + ) :: + DateTime.t() + + @callback in_range?( + Dotcom.Utils.DateTime.date_time_range(), + DateTime.t() + ) :: boolean +end diff --git a/lib/dotcom/utils/service_date_time.ex b/lib/dotcom/utils/service_date_time.ex new file mode 100644 index 0000000000..40c2a9dfb8 --- /dev/null +++ b/lib/dotcom/utils/service_date_time.ex @@ -0,0 +1,209 @@ +defmodule Dotcom.Utils.ServiceDateTime do + @moduledoc """ + A collection of functions that helps to work with date_times with regard to service ranges. + Currently, we consider the most general case where service starts at 03:00:00am and ends at 02:59:59am. + + In the future, we aim to add route-specific service times. + + The service range continuum: + + <---before today---|---later this week---|---next week---|---after next week---> + today + + Before today and after next week are open intervals. + """ + + require Logger + + use Timex + + alias Dotcom.Utils + + @type named_service_range() :: + :before_today | :today | :later_this_week | :next_week | :after_next_week + @date_time_module Application.compile_env!(:dotcom, :date_time_module) + @service_rollover_time Application.compile_env!(:dotcom, :service_rollover_time) + @timezone Application.compile_env!(:dotcom, :timezone) + + @doc """ + Returns the time at which service rolls over from 'today' to 'tomorrow'. + """ + def service_rollover_time(), do: @service_rollover_time + + @doc """ + Get the service date for the given date_time. + If the time is before 03:00:00am, we consider it to be the previous day. + """ + @spec service_date() :: Date.t() + @spec service_date(DateTime.t()) :: Date.t() + def service_date(date_time \\ @date_time_module.now()) do + if date_time.hour < @service_rollover_time.hour do + Timex.shift(date_time, hours: -@service_rollover_time.hour) + |> @date_time_module.coerce_ambiguous_date_time() + |> Timex.to_date() + else + Timex.to_date(date_time) + end + end + + @doc """ + The service range for the given date_time. + """ + @spec service_range(DateTime.t()) :: named_service_range() + def service_range(date_time) do + Enum.find( + [ + &service_before_today?/1, + &service_today?/1, + &service_later_this_week?/1, + &service_next_week?/1, + &service_after_next_week?/1 + ], + fn f -> + f.(date_time) + end + ) + |> Kernel.inspect() + |> Kernel.then(fn module -> Regex.run(~r/_(\w+)\?/, module) end) + |> List.last() + |> String.to_atom() + end + + @doc """ + Get the beginning of the service day for the day after the given date_time. + """ + @spec beginning_of_next_service_day() :: DateTime.t() + @spec beginning_of_next_service_day(DateTime.t()) :: DateTime.t() + def beginning_of_next_service_day(datetime \\ @date_time_module.now()) do + datetime + |> end_of_service_day() + |> Timex.shift(microseconds: 1) + |> @date_time_module.coerce_ambiguous_date_time() + end + + @doc """ + Get the beginning of the service day for the given date_time. + """ + @spec beginning_of_service_day() :: DateTime.t() + @spec beginning_of_service_day(DateTime.t()) :: DateTime.t() + def beginning_of_service_day(date_time \\ @date_time_module.now()) do + date_time + |> service_date() + |> Timex.to_datetime(@timezone) + |> @date_time_module.coerce_ambiguous_date_time() + |> Map.put(:hour, @service_rollover_time.hour) + end + + @doc """ + Get the end of the service day for the given date_time. + """ + @spec end_of_service_day() :: DateTime.t() + @spec end_of_service_day(DateTime.t()) :: DateTime.t() + def end_of_service_day(date_time \\ @date_time_module.now()) do + date_time + |> service_date() + |> Timex.to_datetime(@timezone) + |> @date_time_module.coerce_ambiguous_date_time() + |> Timex.shift(days: 1, hours: @service_rollover_time.hour, microseconds: -1) + |> @date_time_module.coerce_ambiguous_date_time() + |> Map.put(:hour, @service_rollover_time.hour - 1) + end + + @doc """ + Get a service range for the day of the given date_time. + Service days go from 03:00:00am to 02:59:59am the following day. + """ + @spec service_range_day() :: Utils.DateTime.date_time_range() + @spec service_range_day(DateTime.t()) :: Utils.DateTime.date_time_range() + def service_range_day(date_time \\ @date_time_module.now()) do + beginning_of_service_day = beginning_of_service_day(date_time) + end_of_service_day = end_of_service_day(date_time) + + {beginning_of_service_day, end_of_service_day} + end + + @doc """ + Get a service range for the week of the given date_time. + Service weeks go from Monday at 03:00:00am to the following Monday at 02:59:59am. + If today is the last day in the service week, the range will be the same as the range for today. + """ + @spec service_range_later_this_week() :: Utils.DateTime.date_time_range() + @spec service_range_later_this_week(DateTime.t()) :: Utils.DateTime.date_time_range() + def service_range_later_this_week(date_time \\ @date_time_module.now()) do + beginning_of_next_service_day = beginning_of_next_service_day(date_time) + + end_of_later_this_week = date_time |> Timex.end_of_week() |> end_of_service_day() + + case Timex.compare(beginning_of_next_service_day, end_of_later_this_week) do + 1 -> service_range_day(date_time) + _ -> {beginning_of_next_service_day, end_of_later_this_week} + end + end + + @doc """ + Get a service range for the week following the current week of the given date_time. + """ + @spec service_range_next_week() :: Utils.DateTime.date_time_range() + @spec service_range_next_week(DateTime.t()) :: Utils.DateTime.date_time_range() + def service_range_next_week(date_time \\ @date_time_module.now()) do + {_, end_of_later_this_week} = service_range_later_this_week(date_time) + beginning_of_next_week = Timex.shift(end_of_later_this_week, microseconds: 1) + + end_of_next_week = + beginning_of_next_week |> Timex.end_of_week() |> end_of_service_day() + + {beginning_of_next_week, end_of_next_week} + end + + @doc """ + Get a service range for all time after the following week of the given date_time. + """ + @spec service_range_after_next_week() :: Utils.DateTime.date_time_range() + @spec service_range_after_next_week(DateTime.t()) :: Utils.DateTime.date_time_range() + def service_range_after_next_week(date_time \\ @date_time_module.now()) do + {_, end_of_next_week} = date_time |> service_range_next_week() + beginning_of_after_next_week = Timex.shift(end_of_next_week, microseconds: 1) + + {beginning_of_after_next_week, nil} + end + + @doc """ + Is the given date_time before the beginning of service today? + """ + @spec service_before_today?(DateTime.t()) :: boolean + def service_before_today?(date_time) do + Timex.before?(date_time, beginning_of_service_day()) + end + + @doc """ + Does the given date_time fall within today's service range? + """ + @spec service_today?(DateTime.t()) :: boolean + def service_today?(date_time) do + service_range_day() |> @date_time_module.in_range?(date_time) + end + + @doc """ + Does the given date_time fall within the service range of this week? + """ + @spec service_later_this_week?(DateTime.t()) :: boolean + def service_later_this_week?(date_time) do + service_range_later_this_week() |> @date_time_module.in_range?(date_time) + end + + @doc """ + Does the given date_time fall within the service range of next week? + """ + @spec service_next_week?(DateTime.t()) :: boolean + def service_next_week?(date_time) do + service_range_next_week() |> @date_time_module.in_range?(date_time) + end + + @doc """ + Does the given date_time fall within the service range after next week? + """ + @spec service_after_next_week?(DateTime.t()) :: boolean + def service_after_next_week?(date_time) do + service_range_after_next_week() |> @date_time_module.in_range?(date_time) + end +end diff --git a/mix.exs b/mix.exs index d8c32e9108..ee4474a3e7 100644 --- a/mix.exs +++ b/mix.exs @@ -141,6 +141,7 @@ defmodule DotCom.Mixfile do {:sentry, "10.8.1"}, {:server_sent_event_stage, "1.2.1"}, {:sizeable, "1.0.2"}, + {:stream_data, "1.1.3", only: [:dev, :test]}, {:sweet_xml, "0.7.5", only: [:dev, :prod]}, {:telemetry, "1.3.0", override: true}, {:telemetry_metrics, "1.1.0", override: true}, diff --git a/mix.lock b/mix.lock index f5608c24c5..beb0453dd6 100644 --- a/mix.lock +++ b/mix.lock @@ -112,6 +112,7 @@ "sizeable": {:hex, :sizeable, "1.0.2", "625fe06a5dad188b52121a140286f1a6ae1adf350a942cf419499ecd8a11ee29", [:mix], [], "hexpm", "4bab548e6dfba777b400ca50830a9e3a4128e73df77ab1582540cf5860601762"}, "slipstream": {:hex, :slipstream, "1.1.3", "26bfd2b91c75bde3eb4f13fdb25940272395a1b04bcbc48b017f63f40fd09b29", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mint_web_socket, "~> 0.2 or ~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.1 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b1e81cca0369834060077ff43a7e557200dc33a22300e39524a2f5edec9eb023"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "stream_data": {:hex, :stream_data, "1.1.3", "15fdb14c64e84437901258bb56fc7d80aaf6ceaf85b9324f359e219241353bfb", [:mix], [], "hexpm", "859eb2be72d74be26c1c4f272905667672a52e44f743839c57c7ee73a1a66420"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, diff --git a/test/dotcom/utils/date_time_test.exs b/test/dotcom/utils/date_time_test.exs new file mode 100644 index 0000000000..5803cb0e2c --- /dev/null +++ b/test/dotcom/utils/date_time_test.exs @@ -0,0 +1,113 @@ +defmodule Dotcom.Utils.DateTimeTest do + use ExUnit.Case + use ExUnitProperties + + import Dotcom.Utils.DateTime + import Mox + import Test.Support.Generators.DateTime + + @timezone Application.compile_env!(:dotcom, :timezone) + + setup _ do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + + :ok + end + + describe "now/0" do + test "returns the current date_time in either EDT or EST" do + # Exercise + %DateTime{zone_abbr: timezone} = now() + + # Verify + assert timezone in ["EDT", "EST"] + end + end + + describe "coerce_ambiguous_date_time/1" do + test "returns the given date_time when given a date_time" do + # Setup + date_time = now() + + # Exercise/Verify + assert %DateTime{} = coerce_ambiguous_date_time(date_time) + end + + test "chooses the later time when given an ambiguous date_time" do + # Setup + now = now() + later = Timex.shift(now, microseconds: 1) + ambiguous_date_time = %Timex.AmbiguousDateTime{before: now, after: later} + + # Exercise/Verify + assert later == coerce_ambiguous_date_time(ambiguous_date_time) + end + + test "chooses 03:00:00am of the given day when an error tuple is given" do + # Setup + error_date_time = Timex.to_datetime(~N[2021-03-14 02:30:00], @timezone) + rounded_error_date_time = Timex.to_datetime(~N[2021-03-14 03:00:00.000000], @timezone) + + # Exercise/Verify + assert rounded_error_date_time == coerce_ambiguous_date_time(error_date_time) + end + end + + describe "in_range?/2" do + test "returns false when no actual range is given" do + date_time = random_date_time() + + range = {nil, nil} + + refute in_range?(range, date_time) + end + + test "defaults to false" do + range = {:foo, :bar} + + refute in_range?(range, :baz) + end + + test "returns true when the date_time is the start of the range" do + date_time = random_date_time() + + range = {date_time, nil} + + assert in_range?(range, date_time) + end + + test "returns true when the date_time is the end of the range" do + date_time = random_date_time() + + range = {nil, date_time} + + assert in_range?(range, date_time) + end + + property "returns true when the date_time is within the range" do + # Setup + check all(date_time <- date_time_generator()) do + start = Timex.shift(date_time, years: -1) |> coerce_ambiguous_date_time() + stop = Timex.shift(date_time, years: 1) |> coerce_ambiguous_date_time() + + range = {start, stop} + + # Exercise / Verify + assert in_range?(range, date_time) + end + end + + property "returns false when the date_time is not within the range" do + # Setup + check all(date_time <- date_time_generator()) do + start = Timex.shift(date_time, seconds: 1) |> coerce_ambiguous_date_time() + stop = Timex.shift(start, years: 1) |> coerce_ambiguous_date_time() + + range = {start, stop} + + # Exercise / Verify + refute in_range?(range, date_time) + end + end + end +end diff --git a/test/dotcom/utils/service_date_time_test.exs b/test/dotcom/utils/service_date_time_test.exs new file mode 100644 index 0000000000..d359f0a004 --- /dev/null +++ b/test/dotcom/utils/service_date_time_test.exs @@ -0,0 +1,221 @@ +defmodule Dotcom.Utils.ServiceDateTimeTest do + use ExUnit.Case + use ExUnitProperties + + import Mox + import Dotcom.Utils.ServiceDateTime + import Test.Support.Generators.DateTime + import Test.Support.Generators.ServiceDateTime + + setup _ do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + + :ok + end + + describe "service_rollover_time/0" do + test "returns a time for the service rollover time" do + # Exercise/Verify + assert %Time{} = service_rollover_time() + end + end + + describe "service_date/1" do + property "returns 'today' when the date_time is between the start of service and midnight" do + # Setup + check all(date_time <- date_time_generator(:before_midnight)) do + beginning_of_service_day = beginning_of_service_day(date_time) + end_of_day = Timex.end_of_day(date_time) + + date_time_generator = + time_range_date_time_generator({beginning_of_service_day, end_of_day}) + + check all(service_date_time <- date_time_generator) do + # Exercise + date = Timex.to_date(date_time) + service_date = service_date(service_date_time) + + # Verify + assert Timex.equal?(date, service_date, :day) + end + end + end + + property "returns 'yesterday' when the date_time is between midnight and the end of service" do + # Setup + check all(date_time <- date_time_generator(:after_midnight)) do + beginning_of_day = Timex.end_of_day(date_time) |> Timex.shift(microseconds: 1) + end_of_service_day = end_of_service_day(date_time) + + date_time_generator = + time_range_date_time_generator({beginning_of_day, end_of_service_day}) + + check all(service_date_time <- date_time_generator) do + yesterday = service_date_time |> Timex.shift(days: -1) + + # Exercise + date = Timex.to_date(yesterday) + service_date = service_date(date_time) + + # Verify + assert Timex.equal?(date, service_date, :day) + end + end + end + end + + describe "service_range/1" do + test "returns :before_today for before_today" do + # Setup + today = beginning_of_service_day() + before_today = Timex.shift(today, microseconds: -1) + + # Exercise/Verify + assert service_range(before_today) == :before_today + end + + test "returns :today for today" do + # Setup + today = service_range_day() |> random_time_range_date_time() + + # Exercise/Verify + assert service_range(today) == :today + end + + test "returns :today if today is the last day of the service week" do + # Setup + expect(Dotcom.Utils.DateTime.Mock, :now, 2, fn -> + Dotcom.Utils.DateTime.now() |> Timex.beginning_of_week() |> beginning_of_service_day() + end) + + {_, end_of_service_week} = service_range_later_this_week() + + expect(Dotcom.Utils.DateTime.Mock, :now, 1, fn -> + end_of_service_week + end) + + # Exercise / Verify + assert service_range(end_of_service_week) == :today + end + + test "returns :later_this_week for later this week" do + # Setup + {beginning_of_service_week, end_of_service_week} = service_range_later_this_week() + + stub(Dotcom.Utils.DateTime.Mock, :now, fn -> + beginning_of_service_week + end) + + second_service_day = beginning_of_service_week |> beginning_of_next_service_day() + later_this_week = random_time_range_date_time({second_service_day, end_of_service_week}) + + # Exercise / Verify + assert service_range(later_this_week) == :later_this_week + end + + test "returns :next_week for next week" do + # Setup + next_week = service_range_next_week() |> random_time_range_date_time() + + # Exercise / Verify + assert service_range(next_week) == :next_week + end + + test "returns :after_next_week for after_next_week" do + # Setup + after_next_week = service_range_after_next_week() |> random_time_range_date_time() + + # Exercise / Verify + assert service_range(after_next_week) == :after_next_week + end + end + + describe "beginning_of_next_service_day/1" do + property "the beginning of the next service day is the same 'day' as the end of the current service day" do + check all(date_time <- date_time_generator()) do + # Setup + end_of_service_day = end_of_service_day(date_time) + + # Exercise + beginning_of_next_service_day = beginning_of_next_service_day(date_time) + + # Verify + assert Timex.equal?(end_of_service_day, beginning_of_next_service_day, :day) + end + end + end + + describe "beginning_of_service_day/1" do + property "the beginning of the service day is always 3am" do + check all(date_time <- date_time_generator()) do + # Exercise + beginning_of_service_day = beginning_of_service_day(date_time) + + # Verify + assert same_time?(beginning_of_service_day, ~T[03:00:00]) + end + end + end + + describe "end_of_service_day/1" do + property "the end of the service day is always 2:59:59..am" do + check all(date_time <- date_time_generator()) do + # Exercise + end_of_service_day = end_of_service_day(date_time) + + # Verify + assert same_time?(end_of_service_day, ~T[02:59:59]) + end + end + end + + describe "service_today?/1" do + test "returns true when the date_time is in today's service" do + # Setup + today = service_range_day() |> random_time_range_date_time() + + # Exercise / Verify + assert service_today?(today) + end + end + + describe "service_later_this_week?/1" do + test "returns true when the date_time is in this week's service" do + # Setup + {_, end_of_current_service_week} = service_range_later_this_week() + beginning_of_next_service_day = beginning_of_next_service_day() + + service_range_date_time = + random_time_range_date_time({end_of_current_service_week, beginning_of_next_service_day}) + + # Exercise / Verify + assert service_later_this_week?(service_range_date_time) + end + end + + describe "service_next_week?/1" do + test "returns true when the date_time is in next week's service" do + # Setup + next_week = service_range_next_week() |> random_time_range_date_time() + + # Exercise / Verify + assert service_next_week?(next_week) + end + end + + describe "service_after_next_week?/1" do + test "returns true when the date_time is after next week's service" do + # Setup + after_next_week = service_range_after_next_week() |> random_time_range_date_time() + + # Exercise / Verify + assert service_after_next_week?(after_next_week) + end + end + + # Do the two date_times share the same time information (to second granularity)? + defp same_time?(date_time1, date_time2) do + Map.take(date_time1, [:hour, :minute, :second]) == + Map.take(date_time2, [:hour, :minute, :second]) + end +end diff --git a/test/support/generators/date_time.ex b/test/support/generators/date_time.ex new file mode 100644 index 0000000000..cb738bf65d --- /dev/null +++ b/test/support/generators/date_time.ex @@ -0,0 +1,44 @@ +defmodule Test.Support.Generators.DateTime do + @moduledoc """ + Factories to help generate/evaluate date_times for testing. + """ + + @date_time_module Application.compile_env!(:dotcom, :date_time_module) + @timezone Application.compile_env!(:dotcom, :timezone) + + @doc "Generate a random date_time between 10 years ago and 10 years from now." + def date_time_generator() do + now = @date_time_module.now() + + beginning_of_time = + Timex.shift(now, years: -10) |> @date_time_module.coerce_ambiguous_date_time() + + end_of_time = Timex.shift(now, years: 10) |> @date_time_module.coerce_ambiguous_date_time() + + time_range_date_time_generator({beginning_of_time, end_of_time}) + end + + @doc "Generate a random date_time between 10 years ago and 10 years from now." + def random_date_time() do + date_time_generator() |> Enum.take(1) |> List.first() + end + + @doc "Get a random date_time between the beginning and end of the time range." + def random_time_range_date_time({start, stop}) do + time_range_date_time_generator({start, stop}) |> Enum.take(1) |> List.first() + end + + @doc "Generate a random date_time between the beginning and end of the time range." + def time_range_date_time_generator({start, nil}) do + stop = Timex.shift(start, years: 10) + time_range_date_time_generator({start, stop}) + end + + def time_range_date_time_generator({start, stop}) do + StreamData.repeatedly(fn -> + Faker.DateTime.between(start, stop) + |> Timex.to_datetime(@timezone) + |> @date_time_module.coerce_ambiguous_date_time() + end) + end +end diff --git a/test/support/generators/service_date_time.ex b/test/support/generators/service_date_time.ex new file mode 100644 index 0000000000..3cdd8306bd --- /dev/null +++ b/test/support/generators/service_date_time.ex @@ -0,0 +1,31 @@ +defmodule Test.Support.Generators.ServiceDateTime do + @moduledoc """ + Factories to help generate/evaluate service date_times for testing. + """ + + import Dotcom.Utils.ServiceDateTime, only: [end_of_service_day: 1] + + import Test.Support.Generators.DateTime, + only: [date_time_generator: 0, time_range_date_time_generator: 1] + + @doc "Generate a random date_time before midnight or between midnight and 3am." + def date_time_generator(:after_midnight) do + random_date_time = date_time_generator() |> Enum.take(1) |> List.first() + random_hour = Enum.random(0..2) + + after_midnight = Map.put(random_date_time, :hour, random_hour) + end_of_service_day = end_of_service_day(after_midnight) + + time_range_date_time_generator({after_midnight, end_of_service_day}) + end + + def date_time_generator(:before_midnight) do + random_date_time = date_time_generator() |> Enum.take(1) |> List.first() + random_hour = Enum.random(3..23) + + before_midnight = Map.put(random_date_time, :hour, random_hour) + end_of_day = Timex.end_of_day(before_midnight) + + time_range_date_time_generator({before_midnight, end_of_day}) + end +end diff --git a/test/support/mocks.ex b/test/support/mocks.ex index 91e0612c27..7de1066374 100644 --- a/test/support/mocks.ex +++ b/test/support/mocks.ex @@ -10,6 +10,7 @@ Mox.defmock(CMS.Api.Mock, for: CMS.Api.Behaviour) Mox.defmock(Dotcom.Redis.Mock, for: Dotcom.Redis.Behaviour) Mox.defmock(Dotcom.Redix.Mock, for: Dotcom.Redix.Behaviour) Mox.defmock(Dotcom.Redix.PubSub.Mock, for: Dotcom.Redix.PubSub.Behaviour) +Mox.defmock(Dotcom.Utils.DateTime.Mock, for: Dotcom.Utils.DateTime.Behaviour) Mox.defmock(LocationService.Mock, for: LocationService.Behaviour) Mox.defmock(MBTA.Api.Mock, for: MBTA.Api.Behaviour) Mox.defmock(OpenTripPlannerClient.Mock, for: OpenTripPlannerClient.Behaviour)