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