From 25af2d09f1436f34fdfa9847f2e79e3ec2755eef Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Wed, 5 Feb 2025 09:24:48 -0500 Subject: [PATCH] feat: Create a data structure to populate the system status widget (#2330) --- lib/dotcom/system_status.ex | 18 +- lib/dotcom/system_status/subway.ex | 381 ++++++++++++++ lib/dotcom_web/live/system_status.ex | 21 +- test/dotcom/system_status/subway_test.exs | 580 ++++++++++++++++++++++ 4 files changed, 990 insertions(+), 10 deletions(-) create mode 100644 lib/dotcom/system_status/subway.ex create mode 100644 test/dotcom/system_status/subway_test.exs diff --git a/lib/dotcom/system_status.ex b/lib/dotcom/system_status.ex index c6e00440bf..ed42e014c5 100644 --- a/lib/dotcom/system_status.ex +++ b/lib/dotcom/system_status.ex @@ -1,13 +1,8 @@ defmodule Dotcom.SystemStatus do @moduledoc """ - The system status feature is a widget that's intended to appear on the - homepage, as well as eventually several other places throughout the site. - The widget will show statuses for each of the subway lines (Red, Orange, - Green, Blue), which might look like "Normal Service", or information - about current or upcoming alerts. - - This module is responsible for reporting data in a format that can easily - be plugged into a component on the frontend. + This module exists to provide a simple view into the status of the + subway system, for each line, showing whether its running normally, + or whether there are alerts that impact service. """ alias Dotcom.SystemStatus @@ -36,4 +31,11 @@ defmodule Dotcom.SystemStatus do |> SystemStatus.Alerts.for_day(datetime) |> SystemStatus.Alerts.filter_relevant() end + + @doc """ + Returns a map indicating the subway status for each of the subway lines. + """ + def subway_status() do + subway_alerts_for_today() |> SystemStatus.Subway.subway_status(Timex.now()) + end end diff --git a/lib/dotcom/system_status/subway.ex b/lib/dotcom/system_status/subway.ex new file mode 100644 index 0000000000..ef6907ce02 --- /dev/null +++ b/lib/dotcom/system_status/subway.ex @@ -0,0 +1,381 @@ +defmodule Dotcom.SystemStatus.Subway do + @moduledoc """ + A module that groups alerts into statuses for the system status + widget. See `Dotcom.SystemStatus` for more information. + """ + + alias Alerts.Alert + + @lines ["Blue", "Green", "Orange", "Red"] + @green_line_branches ["Green-B", "Green-C", "Green-D", "Green-E"] + + @doc """ + Translates the given alerts into a map indicating the overall system + status for each line. + + ## Example + iex> alerts = + ...> [ + ...> %Alerts.Alert{ + ...> effect: :shuttle, + ...> informed_entity: [%Alerts.InformedEntity{route: "Orange"}], + ...> active_period: [{Timex.beginning_of_day(Timex.now()), nil}] + ...> } + ...> ] + iex> Dotcom.SystemStatus.Subway.subway_status(alerts, Timex.now()) + %{ + "Blue" => [%{branch_ids: [], status_entries: [%{time: :current, status: :normal, multiple: false}]}], + "Orange" => [ + %{ + branch_ids: [], + status_entries: [ + %{time: :current, status: :shuttle, multiple: false} + ] + } + ], + "Red" => [%{branch_ids: [], status_entries: [%{time: :current, status: :normal, multiple: false}]}], + "Green" => [%{branch_ids: [], status_entries: [%{time: :current, status: :normal, multiple: false}]}] + } + + Alerts for individual Green line branches are grouped together and + presented under "Green", rather than being presented under a branch + ID like "Green-D". + + ## Example + iex> alerts = + ...> [ + ...> %Alerts.Alert{ + ...> effect: :delay, + ...> informed_entity: [%Alerts.InformedEntity{route: "Green-E"}], + ...> active_period: [{Timex.beginning_of_day(Timex.now()), nil}] + ...> }, + ...> %Alerts.Alert{ + ...> effect: :delay, + ...> informed_entity: [%Alerts.InformedEntity{route: "Green-D"}], + ...> active_period: [{Timex.beginning_of_day(Timex.now()), nil}] + ...> } + ...> ] + iex> Dotcom.SystemStatus.Subway.subway_status(alerts, Timex.now()) + %{ + "Blue" => [%{branch_ids: [], status_entries: [%{time: :current, status: :normal, multiple: false}]}], + "Orange" => [%{branch_ids: [], status_entries: [%{time: :current, status: :normal, multiple: false}]}], + "Red" => [%{branch_ids: [], status_entries: [%{time: :current, status: :normal, multiple: false}]}], + "Green" => [ + %{ + branch_ids: ["Green-D", "Green-E"], + status_entries: [ + %{time: :current, status: :delay, multiple: false} + ] + }, + %{ + branch_ids: ["Green-B", "Green-C"], + status_entries: [ + %{time: :current, status: :normal, multiple: false} + ] + } + ] + } + + The Mattapan line is usually not shown, but if it has any alerts, + then it's shown as a branch of the Red line. + + ## Example + iex> alerts = + ...> [ + ...> %Alerts.Alert{ + ...> effect: :suspension, + ...> informed_entity: [%Alerts.InformedEntity{route: "Mattapan"}], + ...> active_period: [{Timex.beginning_of_day(Timex.now()), nil}] + ...> } + ...> ] + iex> Dotcom.SystemStatus.Subway.subway_status(alerts, Timex.now()) + %{ + "Blue" => [%{branch_ids: [], status_entries: [%{time: :current, status: :normal, multiple: false}]}], + "Orange" => [%{branch_ids: [], status_entries: [%{time: :current, status: :normal, multiple: false}]}], + "Red" => [ + %{ + branch_ids: [], + status_entries: [ + %{time: :current, status: :normal, multiple: false} + ] + }, + %{ + branch_ids: ["Mattapan"], + status_entries: [ + %{time: :current, status: :suspension, multiple: false} + ] + } + ], + "Green" => [%{branch_ids: [], status_entries: [%{time: :current, status: :normal, multiple: false}]}] + } + """ + def subway_status(alerts, time) do + @lines + |> Map.new(fn line -> + %{route_id: route_id, branches_with_statuses: branches_with_statuses} = + add_nested_statuses_for_line(line, alerts, time) + + {route_id, branches_with_statuses} + end) + end + + # Returns a map corresponding to a single item in the array returned + # by groups/2. + # + # (Note: `line_id` in this context is not necessarily a GTFS + # line_id. The green line branches are grouped under the green line + # both here and in GTFS, but in GTFS, Mattapan is its own line, + # while in this context, Mattapan is grouped under Red.) + # + # The exact implementation depends on which line. Green and Red have + # branches, so they have special implementations. + defp add_nested_statuses_for_line(line_id, alerts, time) + + # Green line nested-statuses implementation: + # + # Finds the alerts for each branch of the green line, maps them to + # statuses, and then groups together any results that have the same + # statuses. + defp add_nested_statuses_for_line("Green", alerts, time) do + %{ + route_id: "Green", + branches_with_statuses: + @green_line_branches + |> Enum.map(&add_statuses_for_route(&1, alerts, time)) + |> group_by_statuses() + |> nest_grouped_statuses_under_branches() + |> sort_branches() + } + end + + # Red line nested-statuses implementation: + # Treat the red line statuses as normal, and add Mattapan if there + # are any. + defp add_nested_statuses_for_line("Red", alerts, time) do + mattapan_branches_with_statuses = + mattapan_branches_with_statuses(alerts, time) + + %{ + route_id: "Red", + branches_with_statuses: + branches_with_statuses("Red", alerts, time) ++ mattapan_branches_with_statuses + } + end + + # Default implementation for a simple subway line (with no + # branches). + defp add_nested_statuses_for_line(route_id, alerts, time) do + %{ + route_id: route_id, + branches_with_statuses: branches_with_statuses(route_id, alerts, time) + } + end + + # Groups the route/status-entry combinations by their statuses so + # that branches with the same statuses can be combined. + defp group_by_statuses(status_entries) do + status_entries |> Enum.group_by(& &1.statuses) |> Enum.to_list() + end + + # Nests grouped statuses under the branches_with_statuses + # field. Exactly how it does this depends on whether + # grouped_statuses has one entry or more. + defp nest_grouped_statuses_under_branches(grouped_statuses) + + # If grouped_statuses has one entry, then that means that all of the + # branches have the same status, which means we don't need to + # specify any branches. + defp nest_grouped_statuses_under_branches([{statuses, _}]) do + [branch_with_statuses_entry(statuses)] + end + + # If grouped_statuses has more than one entry, then we do need to + # specify the branches for each collection of statuses. + defp nest_grouped_statuses_under_branches(grouped_statuses) do + grouped_statuses + |> Enum.map(fn {statuses, entries} -> + branch_ids = entries |> Enum.map(& &1.route_id) |> Enum.sort() + + branch_with_statuses_entry(statuses, branch_ids) + end) + end + + # Sorts green line branches first by alert status (that is, "Normal + # Service" should come after any other alerts), and then by branch + # ID (so that, say statuses for "Green-B" should come ahead of + # "Green-C"). + defp sort_branches(branches_with_statuses) do + branches_with_statuses + |> Enum.sort_by(fn %{status_entries: status_entries, branch_ids: branch_ids} -> + {status_sort_order(status_entries), branch_ids} + end) + end + + # Sort order used in sort_branches/1 - sorts normal statuses ahead + # of alerts. + defp status_sort_order([%{time: :current, status: :normal}]), do: 1 + defp status_sort_order(_), do: 0 + + # Returns a list containing a single status entry group corresponding + # to the alerts for the given route. + defp branches_with_statuses(route_id, alerts, time) do + route_id + |> statuses_for_route(alerts, time) + |> branch_with_statuses_entry() + |> then(&[&1]) + end + + # Behaves mostly like branches_with_statuses/3 when applied to + # "Mattapan", except that if the status is normal, returns an empty + # list. + defp mattapan_branches_with_statuses(alerts, time) do + "Mattapan" + |> alerts_for_route(alerts) + |> case do + [] -> + [] + + mattapan_alerts -> + mattapan_statuses = mattapan_alerts |> alerts_to_statuses(time) + + [branch_with_statuses_entry(mattapan_statuses, ["Mattapan"])] + end + end + + # Exchanges a route_id (a line_id or a branch_id - anything that + # might correspond to an alert) for a map with that route_id and the + # statuses affecting that route. + defp add_statuses_for_route(route_id, alerts, time) do + %{ + route_id: route_id, + statuses: statuses_for_route(route_id, alerts, time) + } + end + + # Returns a list of statuses corresponding to the alerts for the + # given route. + defp statuses_for_route(route_id, alerts, time) do + route_id + |> alerts_for_route(alerts) + |> alerts_to_statuses(time) + end + + # Returns a branch_with_status entry, to be used in the + # branches_with_statuses field in groups/2. If no branch_ids are + # provided, then uses an empty array. + defp branch_with_statuses_entry(statuses, branch_ids \\ []) do + %{ + branch_ids: branch_ids, + status_entries: statuses + } + end + + # Given `alerts` and `route_id`, filters out only the alerts + # applicable to the given route, using the alert's "informed + # entities". + defp alerts_for_route(route_id, alerts) do + alerts + |> Enum.filter(fn %Alert{informed_entity: informed_entity} -> + informed_entity + |> Enum.any?(fn + %{route: ^route_id} -> true + %{} -> false + end) + end) + end + + # Maps a list of alerts to a list of statuses that are formatted + # according to the system status specifications: + # - Identical alerts are grouped together and pluralized. + # - Times are given as a kitchen-formatted string, nil, or "Now". + # - Statuses are sorted alphabetically. + defp alerts_to_statuses(alerts, time) do + alerts + |> alerts_to_statuses_naive(time) + |> consolidate_duplicates() + |> sort_statuses() + end + + # Naively maps a list of alerts to a list of statuses, where a + # status is a simple structure with a route, a status, and a + # few additional fields that determine how it will render in the + # frontend. + defp alerts_to_statuses_naive(alerts, time) + + # If there are no alerts, then we want a single status indicating + # "Normal Service". + defp alerts_to_statuses_naive([], _time) do + [%{multiple: false, status: :normal, time: :current}] + end + + # If there are alerts, then create a starting list of statuses that + # maps one-to-one with the alerts provided. + defp alerts_to_statuses_naive(alerts, time) do + alerts + |> Enum.map(fn alert -> + alert_to_status(alert, time) + end) + end + + # Translates an alert to a status: + # - The effect is humanized into a status for the status. + # - If the alert's already active, `time` is set to `nil`. + # - If the alert is in the future, `time` is set to the alert's + # start time + defp alert_to_status(alert, time) do + time = future_start_time(alert.active_period, time) + + %{multiple: false, status: alert.effect, time: time} + end + + # - If the active period is in the future, returns its start_time. + # - If the active period indicates that the alert is currently active, returns nil. + # - Raises an error if the alert is completely in the past. + defp future_start_time( + [{start_time, _end_time} = first_active_period | more_active_periods], + time + ) do + cond do + ends_before?(first_active_period, time) -> future_start_time(more_active_periods, time) + starts_before?(first_active_period, time) -> :current + true -> {:future, start_time} + end + end + + # Returns true if the active period ends before the time given. An + # end-time of false indicates an indefinite active period, which + # never ends. + defp ends_before?({_start_time, nil}, _time), do: false + defp ends_before?({_start_time, end_time}, time), do: Timex.before?(end_time, time) + + # Returns true if the active period starts before the time given. + defp starts_before?({start_time, _end_time}, time), do: Timex.before?(start_time, time) + + # Combines statuses that have the same active time and status + # into a single pluralized status (e.g. "Station Closures" instead + # of "Station Closure"). + defp consolidate_duplicates(statuses) do + statuses + |> Enum.group_by(fn %{time: time, status: status} -> {time, status} end) + |> Enum.map(fn + {_, [status]} -> status + {_, [status | _]} -> status |> Map.put(:multiple, true) + end) + end + + # Sorts the given list of statuses first by time, then by + # status, so that earlier statuses show up before later ones, + # and then to keep statuses in a stable order. + # + # This takes advantage of the fact that `nil` is sorted before + # anything else, which allows it to automatically sort active + # statuses before future ones. + # + # This should be called before &stringify_times/1, otherwise times + # will get sorted lexically instead of temporally (e.g. 10:00pm will + # get sorted ahead of 9:00pm). + defp sort_statuses(statuses) do + statuses + |> Enum.sort_by(fn %{time: time, status: status} -> {time, status} end) + end +end diff --git a/lib/dotcom_web/live/system_status.ex b/lib/dotcom_web/live/system_status.ex index 8e7457ca9e..1bfb55859c 100644 --- a/lib/dotcom_web/live/system_status.ex +++ b/lib/dotcom_web/live/system_status.ex @@ -4,21 +4,38 @@ defmodule DotcomWeb.Live.SystemStatus do put it into the homepage (and elsewhere). """ - alias Dotcom.SystemStatus use DotcomWeb, :live_view + alias Dotcom.SystemStatus + def render(assigns) do + alerts = SystemStatus.subway_alerts_for_today() + + statuses = SystemStatus.subway_status() + assigns = assigns - |> assign(:alerts, SystemStatus.subway_alerts_for_today()) + |> assign(:alerts, alerts) + |> assign(:statuses, statuses) ~H""" +

System Status

+
+ <.status :for={status <- @statuses} status={status} /> +
+

Alerts

<.alert :for={alert <- @alerts} alert={alert} />
""" end + defp status(assigns) do + ~H""" +
{inspect @status, pretty: true}
+ """ + end + defp alert(assigns) do ~H"""
diff --git a/test/dotcom/system_status/subway_test.exs b/test/dotcom/system_status/subway_test.exs new file mode 100644 index 0000000000..b22e09d053 --- /dev/null +++ b/test/dotcom/system_status/subway_test.exs @@ -0,0 +1,580 @@ +defmodule Dotcom.SystemStatus.SubwayTest do + use ExUnit.Case, async: true + doctest Dotcom.SystemStatus.Subway + + alias Dotcom.SystemStatus.Subway + alias Test.Support.Factories.Alerts.Alert + alias Test.Support.Factories.Alerts.InformedEntity + alias Test.Support.Factories.Alerts.InformedEntitySet + + @all_rail_lines ["Blue", "Green", "Orange", "Red"] + @green_line_branches ["Green-B", "Green-C", "Green-D", "Green-E"] + @lines_without_branches ["Blue", "Orange", "Red"] + + @effects [:delay, :shuttle, :station_closure, :suspension] + + describe "heavy rail groups" do + test "when there are no alerts, lists each line as normal" do + # Exercise + groups = Subway.subway_status([], time_today()) + + # Verify + @all_rail_lines + |> Enum.each(fn route_id -> + statuses = groups |> status_entries_for(route_id) + + assert statuses |> Enum.map(& &1.status) == [:normal] + assert statuses |> Enum.map(& &1.multiple) == [false] + end) + end + + test "when there's an alert for a heavy rail line, shows an entry for that line" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + time = time_today() + effect = Faker.Util.pick(@effects) + alerts = [current_alert(route_id: affected_route_id, time: time, effect: effect)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + [status] = + groups + |> status_entries_for(affected_route_id) + |> Enum.map(& &1.status) + + assert status == effect + end + + test "when there's an alert for a heavy rail line, shows the entry with multiple: false" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + time = time_today() + effect = Faker.Util.pick(@effects) + alerts = [current_alert(route_id: affected_route_id, time: time, effect: effect)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + [multiple] = + groups + |> status_entries_for(affected_route_id) + |> Enum.map(& &1.multiple) + + assert multiple == false + end + + test "when there's a current alert, sets the `time` to :current" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + time = time_today() + alerts = [current_alert(route_id: affected_route_id, time: time)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + times = + groups + |> status_entries_for(affected_route_id) + |> Enum.map(fn s -> s.time end) + + assert times == [:current] + end + + test "when there's an alert for a heavy rail line, shows 'Normal Service' for the other lines" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + time = time_today() + + alerts = [current_alert(route_id: affected_route_id, time: time)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + @lines_without_branches + |> List.delete(affected_route_id) + |> Enum.each(fn route_id -> + statuses = + groups + |> status_entries_for(route_id) + |> Enum.map(fn s -> s.status end) + + assert statuses == [:normal] + end) + end + + test "shows future active time for alerts that will become active later in the day" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + + time = time_today() + alert_start_time = time_after(time) + + alerts = [future_alert(route_id: affected_route_id, start_time: alert_start_time)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + times = + groups + |> status_entries_for(affected_route_id) + |> Enum.map(& &1.time) + + assert times == [{:future, alert_start_time}] + end + + test "shows entry for active alerts with no end time" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + + time = time_today() + effect = Faker.Util.pick(@effects) + alert_start_time = time_before(time) + + alerts = [ + alert( + route_id: affected_route_id, + effect: effect, + active_period: [{alert_start_time, nil}] + ) + ] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + statuses = + groups + |> status_entries_for(affected_route_id) + |> Enum.map(& &1.status) + + assert statuses == [effect] + end + + test "shows a future time for alerts that have an expired active_period as well" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + + time = time_today() + alert_start_time = time_after(time) + alert_end_time = time_after(alert_start_time) + + expired_alert_end_time = time_before(time) + expired_alert_start_time = time_before(expired_alert_end_time) + + alerts = [ + alert( + route_id: affected_route_id, + active_period: [ + {expired_alert_start_time, expired_alert_end_time}, + {alert_start_time, alert_end_time} + ] + ) + ] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + times = + groups + |> status_entries_for(affected_route_id) + |> Enum.map(& &1.time) + + assert times == [{:future, alert_start_time}] + end + + test "shows multiple alerts for a given route, sorted alphabetically" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + + time = time_today() + + # Sorted in reverse order in order to validate that the sorting + # logic works + [effect2, effect1] = + Faker.Util.sample_uniq(2, fn -> Faker.Util.pick(@effects) end) |> Enum.sort(:desc) + + alerts = [ + current_alert(route_id: affected_route_id, time: time, effect: effect1), + current_alert(route_id: affected_route_id, time: time, effect: effect2) + ] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + statuses = + groups + |> status_entries_for(affected_route_id) + |> Enum.map(& &1.status) + + assert statuses == [effect1, effect2] + end + + test "sorts current alerts ahead of future ones" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + + time = time_today() + + future_alert_start_time = time_after(time) + + alerts = [ + future_alert(route_id: affected_route_id, start_time: future_alert_start_time), + current_alert(route_id: affected_route_id, time: time) + ] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + times = + groups + |> status_entries_for(affected_route_id) + |> Enum.map(& &1.time) + + assert times == [:current, {:future, future_alert_start_time}] + end + + test "consolidates current alerts if they have the same effect" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + + time = time_today() + + effect = Faker.Util.pick(@effects) + + alerts = [ + current_alert(route_id: affected_route_id, time: time, effect: effect), + current_alert(route_id: affected_route_id, time: time, effect: effect) + ] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + multiples = + groups + |> status_entries_for(affected_route_id) + |> Enum.map(& &1.multiple) + + assert multiples == [true] + end + + test "consolidates future alerts if they have the same effect and time" do + # Setup + affected_route_id = Faker.Util.pick(@lines_without_branches) + + time = time_today() + start_time = time_after(time) + + effect = Faker.Util.pick(@effects) + + alerts = [ + future_alert(route_id: affected_route_id, start_time: start_time, effect: effect), + future_alert(route_id: affected_route_id, start_time: start_time, effect: effect) + ] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + multiples = + groups + |> status_entries_for(affected_route_id) + |> Enum.map(& &1.multiple) + + assert multiples == [true] + end + end + + describe "green line groups" do + test "combines all green line branches into a single one if they have the same alerts" do + # Setup + time = time_today() + + effect = Faker.Util.pick(@effects) + + alerts = + @green_line_branches + |> Enum.map(fn route_id -> + current_alert(route_id: route_id, time: time, effect: effect) + end) + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + statuses = + groups + |> status_entries_for("Green") + |> Enum.map(& &1.status) + + assert statuses == [effect] + end + + test "splits separate branches of the green line out as sub_routes if some have alerts and others don't" do + # Setup + affected_branch_id = Faker.Util.pick(@green_line_branches) + + time = time_today() + effect = Faker.Util.pick(@effects) + + alerts = [current_alert(route_id: affected_branch_id, effect: effect, time: time)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + statuses = + groups + |> status_entries_for("Green", [affected_branch_id]) + |> Enum.map(& &1.status) + + assert statuses == [effect] + end + + test "includes an 'Normal Service' entry for non-affected green line branches" do + # Setup + affected_branch_id = Faker.Util.pick(@green_line_branches) + + time = time_today() + effect = Faker.Util.pick(@effects) + + alerts = [current_alert(route_id: affected_branch_id, effect: effect, time: time)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + normal_branch_ids = @green_line_branches |> List.delete(affected_branch_id) + + statuses = + groups + |> status_entries_for("Green", normal_branch_ids) + |> Enum.map(& &1.status) + + assert statuses == [:normal] + end + + test "sorts alerts ahead of 'Normal Service'" do + # Setup + affected_branch_id = Faker.Util.pick(@green_line_branches) + + time = time_today() + effect = Faker.Util.pick(@effects) + + alerts = [current_alert(route_id: affected_branch_id, effect: effect, time: time)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + normal_branch_ids = @green_line_branches |> List.delete(affected_branch_id) + + branch_ids = + groups + |> Map.fetch!("Green") + |> Enum.map(& &1.branch_ids) + + assert branch_ids == [ + [affected_branch_id], + normal_branch_ids + ] + end + + test "sorts branches that do have alerts lexically by branch ID" do + # Setup + [affected_branch_id1, affected_branch_id2] = + Faker.Util.sample_uniq(2, fn -> Faker.Util.pick(@green_line_branches) end) + + time = time_today() + [effect1, effect2] = Faker.Util.sample_uniq(2, fn -> Faker.Util.pick(@effects) end) + + alerts = [ + current_alert(route_id: affected_branch_id1, effect: effect1, time: time), + current_alert(route_id: affected_branch_id2, effect: effect2, time: time) + ] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + affected_branch_ids = + groups + |> Map.fetch!("Green") + |> Enum.flat_map(& &1.branch_ids) + |> Enum.take(2) + + assert affected_branch_ids == Enum.sort([affected_branch_id1, affected_branch_id2]) + end + end + + describe "red line groups" do + test "does not include Mattapan as a branch of the red line if Mattapan doesn't have any alerts" do + # Setup + time = time_today() + + alerts = [current_alert(route_id: "Red", time: time)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + red_line_statuses = groups |> Map.fetch!("Red") + + refute red_line_statuses + |> Enum.any?(fn + %{branch_ids: ["Mattapan"]} -> true + _ -> false + end) + end + + test "shows Mattapan as a branch of Red if it has an alert" do + # Setup + time = time_today() + effect = Faker.Util.pick(@effects) + + alerts = [current_alert(route_id: "Mattapan", effect: effect, time: time)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + statuses = + groups + |> status_entries_for("Red", ["Mattapan"]) + |> Enum.map(& &1.status) + + assert statuses == [effect] + end + + test "includes a 'Normal Service' entry for Red if Mattapan has an alert" do + # Setup + time = time_today() + + alerts = [current_alert(route_id: "Mattapan", time: time)] + + # Exercise + groups = Subway.subway_status(alerts, time) + + # Verify + statuses = + groups + |> status_entries_for("Red") + |> Enum.map(& &1.status) + + assert statuses == [:normal] + end + end + + # Returns the statuses for the given route_id and branch_id + # collection. If no branches are specified, then returns the group + # for the given route_id with an empty branch_ids list. + defp status_entries_for(groups, route_id, branch_ids \\ []) do + groups + |> Map.fetch!(route_id) + |> Enum.find(&(&1.branch_ids == branch_ids)) + |> Map.get(:status_entries) + end + + # Returns the beginning of the day in the Eastern time zone. + defp beginning_of_day() do + Timex.beginning_of_day(Timex.now("America/New_York")) + end + + # Returns the end of the day in the Eastern time zone. + defp end_of_day() do + Timex.end_of_day(Timex.now("America/New_York")) + end + + # Returns a random time during the day today. + defp time_today() do + between(beginning_of_day(), end_of_day()) + end + + # Returns a random time during the day today before the time provided. + defp time_before(time) do + between(beginning_of_day(), time) + end + + # Returns a random time during the day today after the time provided. + defp time_after(time) do + between(time, end_of_day()) + end + + # Returns a random time between the times provided in the Eastern time zone. + defp between(time1, time2) do + Faker.DateTime.between(time1, time2) |> Timex.to_datetime("America/New_York") + end + + # Returns a random alert that will be active at the time given by + # the required `:time` opt. + # + # Required opts: + # - route_id + # - time + # + # Optional opts: + # - effect (default behavior is to choose an effect at random) + defp current_alert(opts) do + {time, opts} = opts |> Keyword.pop!(:time) + + start_time = time_before(time) + end_time = time_after(time) + + opts + |> Keyword.put_new(:active_period, [{start_time, end_time}]) + |> alert() + end + + # Returns a random alert whose active_period starts at the provided + # `:start_time` opt. + # + # Required opts: + # - route_id + # - start_time + # + # Optional opts: + # - effect (default behavior is to choose an effect at random) + defp future_alert(opts) do + {start_time, opts} = opts |> Keyword.pop!(:start_time) + + opts + |> Keyword.put_new(:active_period, [{start_time, time_after(start_time)}]) + |> alert() + end + + # Returns a random alert for the given `:route_id` and + # `:active_period` opts. + # + # Required opts: + # - route_id + # - active_period (Note that this is an array) + # + # Optional opts: + # - effect (default behavior is to choose an effect at random) + defp alert(opts) do + route_id = opts |> Keyword.fetch!(:route_id) + effect = opts[:effect] || Faker.Util.pick(@effects) + active_period = opts |> Keyword.fetch!(:active_period) + + Alert.build(:alert, + effect: effect, + informed_entity: + InformedEntitySet.build(:informed_entity_set, + route: route_id, + entities: [ + InformedEntity.build(:informed_entity, route: route_id) + ] + ), + active_period: active_period + ) + end +end