diff --git a/lib/screens/alerts/alert.ex b/lib/screens/alerts/alert.ex
index cfd243f62..f5e4e815b 100644
--- a/lib/screens/alerts/alert.ex
+++ b/lib/screens/alerts/alert.ex
@@ -97,6 +97,10 @@ defmodule Screens.Alerts.Alert do
| :unknown
@type informed_entity :: %{
+ optional(:facility) => %{
+ id: String.t() | nil,
+ name: String.t() | nil
+ },
stop: String.t() | nil,
route: String.t() | nil,
route_type: non_neg_integer() | nil,
@@ -572,6 +576,16 @@ defmodule Screens.Alerts.Alert do
informed_entities
end
+ @doc "Returns IDs of all subway routes affected by the alert. Green Line routes are not consolidated."
+ def informed_subway_routes(%__MODULE__{} = alert) do
+ informed_route_ids = MapSet.new(alert.informed_entities, & &1.route)
+
+ Enum.filter(
+ ["Blue", "Orange", "Red", "Green-B", "Green-C", "Green-D", "Green-E"],
+ &(&1 in informed_route_ids)
+ )
+ end
+
def effect(%__MODULE__{effect: effect}), do: effect
def direction_id(%__MODULE__{informed_entities: informed_entities}),
diff --git a/lib/screens/alerts/informed_entity.ex b/lib/screens/alerts/informed_entity.ex
new file mode 100644
index 000000000..b96fd5cab
--- /dev/null
+++ b/lib/screens/alerts/informed_entity.ex
@@ -0,0 +1,27 @@
+defmodule Screens.Alerts.InformedEntity do
+ @moduledoc """
+ Functions to query alert informed entities.
+ """
+
+ alias Screens.Alerts.Alert
+
+ @type t :: Alert.informed_entity()
+
+ @spec whole_route?(t()) :: boolean
+ def whole_route?(ie) do
+ match?(
+ %{route: route_id, direction_id: nil, stop: nil}
+ when not is_nil(route_id),
+ ie
+ )
+ end
+
+ @spec whole_direction?(t()) :: boolean
+ def whole_direction?(ie) do
+ match?(
+ %{route: route_id, direction_id: direction_id, stop: nil}
+ when not is_nil(route_id) and not is_nil(direction_id),
+ ie
+ )
+ end
+end
diff --git a/lib/screens/alerts/parser.ex b/lib/screens/alerts/parser.ex
index e03956119..72d104227 100644
--- a/lib/screens/alerts/parser.ex
+++ b/lib/screens/alerts/parser.ex
@@ -84,13 +84,9 @@ defmodule Screens.Alerts.Parser do
:error -> nil
end
- %{
- stop: get_in(ie, ["stop"]),
- route: get_in(ie, ["route"]),
- route_type: get_in(ie, ["route_type"]),
- direction_id: get_in(ie, ["direction_id"]),
- facility: %{id: facility_id, name: facility_name}
- }
+ ie
+ |> parse_informed_entity()
+ |> Map.put(:facility, %{id: facility_id, name: facility_name})
end
defp parse_and_sort_active_periods(periods) do
diff --git a/lib/screens/route_patterns/route_pattern.ex b/lib/screens/route_patterns/route_pattern.ex
index 0aef0c18b..e25186356 100644
--- a/lib/screens/route_patterns/route_pattern.ex
+++ b/lib/screens/route_patterns/route_pattern.ex
@@ -73,11 +73,15 @@ defmodule Screens.RoutePatterns.RoutePattern do
Returns a map from route ID to a list of stop sequences of that route. Stop sequences
are described in terms of parent station IDs, not platform IDs.
- For most routes (everything but Red Line), only one stop sequence will be in the list.
- For Red Line, the list will contain one stop sequence for the Ashmont branch and one for the Braintree branch.
+ Pass `true` for `canonical_only?` to limit results to canonical route patterns.
+ With `canonical_only? = true`,
+ - For most routes (everything but Red Line), only one stop sequence will be in the list.
+ - For Red Line, the list will contain one stop sequence for the Ashmont branch and one for the Braintree branch.
+
+ Pass `false` for `canonical_only?` to limit results to *non-canonical* route patterns. (You probably don't want to do this!)
If no parent station data exists, platform_id is returned instead.
- Only stop sequences for one direction of travel are returned.
+ Only stop sequences for direction ID 0 are returned.
Assumes that all stop sequences in result are platforms.
"""
@spec fetch_tagged_parent_station_sequences_through_stop(Stop.id(), list(String.t())) ::
@@ -85,6 +89,7 @@ defmodule Screens.RoutePatterns.RoutePattern do
def fetch_tagged_parent_station_sequences_through_stop(
stop_id,
route_filters,
+ canonical_only? \\ nil,
get_json_fn \\ &V3Api.get_json/2
) do
params = %{
@@ -94,6 +99,11 @@ defmodule Screens.RoutePatterns.RoutePattern do
"filter[route]" => Enum.join(route_filters, ",")
}
+ params =
+ if is_boolean(canonical_only?) do
+ Map.put(params, "filter[canonical]", canonical_only?)
+ end
+
case get_json_fn.("route_patterns", params) do
{:ok, result} ->
{:ok, get_tagged_parent_station_sequences_from_result(result)}
diff --git a/lib/screens/stops/stop.ex b/lib/screens/stops/stop.ex
index 99d8a5051..01793063a 100644
--- a/lib/screens/stops/stop.ex
+++ b/lib/screens/stops/stop.ex
@@ -15,6 +15,7 @@ defmodule Screens.Stops.Stop do
alias Screens.RoutePatterns.RoutePattern
alias Screens.Routes
alias Screens.Routes.Route
+ alias Screens.RouteType
alias Screens.Stops.StationsWithRoutesAgent
alias Screens.Util
alias Screens.V3Api
@@ -208,8 +209,6 @@ defmodule Screens.Stops.Stop do
]
@green_line_trunk_stops [
- # These 3 eventually will NOT be trunk stops, but are until Medford opens
- {"place-unsqu", {"Union Square", "Union Sq"}},
{"place-lech", {"Lechmere", "Lechmere"}},
{"place-spmnl", {"Science Park/West End", "Science Pk"}},
{"place-north", {"North Station", "North Sta"}},
@@ -223,6 +222,18 @@ defmodule Screens.Stops.Stop do
{"place-kencl", {"Kenmore", "Kenmore"}}
]
+ @medford_tufts_branch_stops [
+ {"place-mdftf", {"Medford / Tufts", "Medford"}},
+ {"place-balsq", {"Ball Square", "Ball Sq"}},
+ {"place-mgngl", {"Magoun Square", "Magoun Sq"}},
+ {"place-gilmn", {"Gilman Square", "Gilman Sq"}},
+ {"place-esomr", {"East Somerville", "E Somerville"}}
+ ]
+
+ @union_square_branch_stops [
+ {"place-unsqu", {"Union Square", "Union Sq"}}
+ ]
+
@route_stop_sequences %{
"Blue" => [@blue_line_stops],
"Orange" => [@orange_line_stops],
@@ -432,6 +443,10 @@ defmodule Screens.Stops.Stop do
@green_line_trunk_stops
end
+ def rl_trunk_stops do
+ @red_line_trunk_stops
+ end
+
def stop_id_to_name(route_id) do
@route_stop_sequences
|> Map.get(route_id)
@@ -451,18 +466,8 @@ defmodule Screens.Stops.Stop do
def fetch_location_context(app, stop_id, now) do
with alert_route_types <- get_route_type_filter(app, stop_id),
{:ok, routes_at_stop} <- Route.fetch_routes_by_stop(stop_id, now, alert_route_types),
- route_ids <- Route.route_ids(routes_at_stop),
{:ok, tagged_stop_sequences} <-
- (cond do
- app in [BusEink, BusShelter, GlEink] ->
- RoutePattern.fetch_tagged_stop_sequences_through_stop(stop_id)
-
- app in [PreFare, Dup] ->
- RoutePattern.fetch_tagged_parent_station_sequences_through_stop(
- stop_id,
- route_ids
- )
- end) do
+ fetch_tagged_stop_sequences_by_app(app, stop_id, routes_at_stop) do
stop_name = fetch_stop_name(stop_id)
stop_sequences = RoutePattern.untag_stop_sequences(tagged_stop_sequences)
@@ -488,7 +493,7 @@ defmodule Screens.Stops.Stop do
# Returns the route types we care about for the alerts of this screen type / place
@spec get_route_type_filter(screen_type(), String.t()) ::
- list(atom())
+ list(RouteType.t())
def get_route_type_filter(app, _) when app in [BusEink, BusShelter], do: [:bus]
def get_route_type_filter(GlEink, _), do: [:light_rail]
# Ashmont should not show Mattapan alerts for PreFare or Dup
@@ -511,4 +516,27 @@ defmodule Screens.Stops.Stop do
|> Enum.flat_map(fn stop_sequence -> Util.slice_after(stop_sequence, stop_id) end)
|> MapSet.new()
end
+
+ def on_glx?(stop_id) do
+ stop_id in Enum.map(@medford_tufts_branch_stops ++ @union_square_branch_stops, &elem(&1, 0))
+ end
+
+ defp fetch_tagged_stop_sequences_by_app(app, stop_id, _routes_at_stop)
+ when app in [BusEink, BusShelter, GlEink] do
+ RoutePattern.fetch_tagged_stop_sequences_through_stop(stop_id)
+ end
+
+ defp fetch_tagged_stop_sequences_by_app(app, stop_id, routes_at_stop)
+ when app in [Dup] do
+ route_ids = Route.route_ids(routes_at_stop)
+ RoutePattern.fetch_tagged_parent_station_sequences_through_stop(stop_id, route_ids, false)
+ end
+
+ defp fetch_tagged_stop_sequences_by_app(app, stop_id, routes_at_stop)
+ when app == PreFare do
+ route_ids = Route.route_ids(routes_at_stop)
+
+ # We limit results to canonical route patterns only--no stop sequences for nonstandard patterns.
+ RoutePattern.fetch_tagged_parent_station_sequences_through_stop(stop_id, route_ids, true)
+ end
end
diff --git a/lib/screens/v2/disruption_diagram.ex b/lib/screens/v2/disruption_diagram.ex
new file mode 100644
index 000000000..86c0c3a23
--- /dev/null
+++ b/lib/screens/v2/disruption_diagram.ex
@@ -0,0 +1,87 @@
+defmodule Screens.V2.DisruptionDiagram do
+ @moduledoc """
+ Public interface for generating disruption diagrams.
+ """
+
+ alias Screens.V2.DisruptionDiagram.Model
+ alias Screens.V2.LocalizedAlert
+
+ # We don't need to define any new struct for the diagram's source data--
+ # we can use any map/struct that satisfies LocalizedAlert.t().
+ @type t :: LocalizedAlert.t()
+
+ @type serialized_response :: continuous_disruption_diagram() | discrete_disruption_diagram()
+
+ @type continuous_disruption_diagram :: %{
+ effect: :shuttle | :suspension,
+ # A 2-element list, giving indices of the effect region's *boundary stops*, inclusive.
+ # For example in this scenario:
+ # 0 1 2 3 4 5 6 7 8
+ # <= === O ========= O - - X - - X - - X - - O === O
+ # |---------range---------|
+ # The range is [3, 7].
+ #
+ # SPECIAL CASE:
+ # If the range starts at 0 or ends at the last element of the array,
+ # then the symbol for that terminal stop should use the appropriate
+ # disruption symbol, not the "normal service" symbol.
+ # For example if the range is [0, 5], the left end of the
+ # diagram should use a disruption symbol:
+ # 0 1 2 3 4 5 6 7 8
+ # X - - X - - X - - X - - X - - O ========= O === =>
+ # |------------range------------|
+ effect_region_slot_index_range: {non_neg_integer(), non_neg_integer()},
+ line: line(),
+ current_station_slot_index: non_neg_integer(),
+ # First and last elements of the list are `end_slot`s, middle elements are `middle_slot`s.
+ slots: list(slot())
+ }
+
+ @type discrete_disruption_diagram :: %{
+ effect: :station_closure,
+ closed_station_slot_indices: list(non_neg_integer()),
+ line: line(),
+ current_station_slot_index: non_neg_integer(),
+ # First and last elements of the list are `end_slot`s, middle elements are `middle_slot`s.
+ slots: list(slot())
+ }
+
+ @type slot :: end_slot() | middle_slot()
+
+ @type end_slot :: %{
+ type: :arrow | :terminal,
+ label_id: end_label_id()
+ }
+
+ @type middle_slot :: %{
+ label: label(),
+ show_symbol: boolean()
+ }
+
+ @type label :: label_map() | ellipsis()
+
+ @type label_map :: %{full: String.t(), abbrev: String.t()}
+
+ # Literally the string "…", but you can't use string literals as types in elixir
+ @type ellipsis :: String.t()
+
+ # End labels have hardcoded presentation, so we just send an ID for the client to use in
+ # a lookup.
+ #
+ # In most cases, the IDs are parent station IDs. For compound labels like
+ # "to Ashmont & Braintree", two IDs are joined with '+': "place-asmnl+place-brntn".
+ # For labels that don't use station names, we just use an agreed-upon string:
+ # "western_branches", "place-kencl+west", etc.
+ #
+ # The rest of the labels' presentations are computed based on the height of the end labels,
+ # so we can send actual text for those--it will be dynamically resized to fit.
+ @type end_label_id :: String.t()
+
+ @type line :: :blue | :orange | :red | :green
+
+ @type branch :: :b | :c | :d | :e | :ashmont | :braintree | :trunk
+
+ @doc "Produces a JSON-serializable map representing the disruption diagram."
+ @spec serialize(t()) :: {:ok, serialized_response()} | {:error, reason :: String.t()}
+ defdelegate serialize(localized_alert), to: Model
+end
diff --git a/lib/screens/v2/disruption_diagram/builder.ex b/lib/screens/v2/disruption_diagram/builder.ex
new file mode 100644
index 000000000..7f7f97f0c
--- /dev/null
+++ b/lib/screens/v2/disruption_diagram/builder.ex
@@ -0,0 +1,993 @@
+defmodule Screens.V2.DisruptionDiagram.Builder do
+ @moduledoc """
+ An intermediate data structure for transforming a localized alert to a disruption diagram.
+
+ Values should be accessed/manipulated only via public module functions.
+ """
+
+ alias Aja.Vector
+ alias Screens.Routes.Route
+ alias Screens.Stops.Stop
+ alias Screens.V2.DisruptionDiagram, as: DD
+ alias Screens.V2.DisruptionDiagram.Label
+ alias Screens.V2.LocalizedAlert
+
+ # Vector-related macros
+ import Aja, only: [vec: 1, vec_size: 1, +++: 2]
+
+ ##################
+ # HELPER MODULES #
+ ##################
+
+ defmodule StopSlot do
+ @moduledoc false
+
+ @enforce_keys [:id, :label, :home_stop?, :disrupted?, :terminal?]
+ defstruct @enforce_keys
+
+ @type t :: %__MODULE__{
+ id: Stop.id(),
+ label: DD.label_map(),
+ home_stop?: boolean(),
+ disrupted?: boolean(),
+ terminal?: boolean()
+ }
+ end
+
+ defmodule OmittedSlot do
+ @moduledoc false
+
+ @enforce_keys [:label]
+ defstruct @enforce_keys
+
+ @type t :: %__MODULE__{label: DD.label()}
+ end
+
+ defmodule ArrowSlot do
+ @moduledoc false
+
+ @enforce_keys [:label_id]
+ defstruct @enforce_keys
+
+ @type t :: %__MODULE__{label_id: DD.end_label_id()}
+ end
+
+ defmodule Metadata do
+ @moduledoc false
+
+ @enforce_keys [
+ :line,
+ :effect,
+ :branch,
+ :home_stop,
+ :first_disrupted_stop,
+ :last_disrupted_stop
+ ]
+ defstruct @enforce_keys
+
+ @type t :: %__MODULE__{
+ line: DD.line(),
+ effect: :shuttle | :suspension | :station_closure,
+ branch: DD.branch(),
+ first_disrupted_stop: Vector.index(),
+ last_disrupted_stop: Vector.index(),
+ home_stop: Vector.index()
+ }
+ end
+
+ ###############
+ # MAIN MODULE #
+ ###############
+
+ @enforce_keys [:sequence, :metadata]
+ defstruct @enforce_keys ++ [left_end: Vector.new(), right_end: Vector.new()]
+
+ @type t :: %__MODULE__{
+ # The main sequence of slots in the diagram.
+ sequence: sequence(),
+ # Information about the diagram as a whole, including indexes of important stops.
+ metadata: metadata(),
+ # The ends are "bags" of stops that are outside the main area of the diagram.
+ # Stops can be transferred between the `sequence` and the ends during the process of building the diagram.
+ # Each end serializes to at most 1 slot in the final diagram.
+ # During serialization, we inspect the contents of each end to determine what the first
+ # and last slot should be.
+ left_end: end_sequence(),
+ right_end: end_sequence()
+ }
+
+ # Starts out only containing StopSlots, but may contain other slot types
+ # as we work our way toward building the final diagram output.
+ @opaque sequence :: Vector.t(StopSlot.t() | OmittedSlot.t() | ArrowSlot.t())
+
+ @opaque end_sequence :: Vector.t(StopSlot.t() | ArrowSlot.t())
+
+ @opaque metadata :: Metadata.t()
+
+ @doc "Creates a new Builder from a localized alert."
+ @spec new(LocalizedAlert.t()) :: {:ok, t()} | {:error, reason :: String.t()}
+ def new(localized_alert) do
+ informed_stop_ids =
+ for %{stop: "place-" <> _ = stop_id} <- localized_alert.alert.informed_entities,
+ into: MapSet.new(),
+ do: stop_id
+
+ with {:ok, route_id, stop_sequence, branch} <-
+ get_builder_data(localized_alert, informed_stop_ids) do
+ line = Route.get_color_for_route(route_id)
+
+ stop_id_to_name = Stop.stop_id_to_name(route_id)
+
+ slot_sequence =
+ stop_sequence
+ |> Vector.new(fn stop_id ->
+ {full, abbrev} = Map.fetch!(stop_id_to_name, stop_id)
+
+ %StopSlot{
+ id: stop_id,
+ label: %{full: full, abbrev: abbrev},
+ home_stop?: stop_id == localized_alert.location_context.home_stop,
+ disrupted?: stop_id in informed_stop_ids,
+ terminal?: false
+ }
+ end)
+ |> adjust_ends(line, branch)
+
+ init_metadata = %Metadata{
+ line: line,
+ branch: branch,
+ effect: localized_alert.alert.effect,
+ # These will get the correct values during the first `recalculate_metadata` run below.
+ home_stop: -1,
+ first_disrupted_stop: -1,
+ last_disrupted_stop: -1
+ }
+
+ builder =
+ %__MODULE__{sequence: slot_sequence, metadata: init_metadata}
+ |> recalculate_metadata()
+ |> split_end_stops()
+
+ {:ok, builder}
+ end
+ end
+
+ @doc """
+ Reverses the builder's internal stop sequence, so that the last stop comes first and vice versa.
+
+ This is helpful for cases where the disruption diagram lists stops in the opposite order of
+ the direction_id=0 route order, e.g. in Blue Line diagrams where we show Bowdoin first but
+ direction_id=0 has Wonderland listed first.
+ """
+ @spec reverse(t()) :: t()
+ def reverse(%__MODULE__{} = builder) do
+ %{
+ builder
+ | sequence: Vector.reverse(builder.sequence),
+ # The ends swap places, and also have their elements flipped.
+ left_end: Vector.reverse(builder.right_end),
+ right_end: Vector.reverse(builder.left_end)
+ }
+ |> recalculate_metadata()
+ end
+
+ @doc """
+ Tries to omit stops from the given region, replacing them with a labeled "blank" slot, or two in rare cases.
+ `target_slots` gives the desired number of remaining slots in the region after omission.
+
+ Stops are omitted from the center of the region, unless that would result
+ in the omission of the home stop or a bypassed stop.
+ In that case, we try to find another segment, or segments, of stops to omit, staying as close to the center as possible.
+
+ Returns an error result if it's not possible to omit the required number of stops without
+ also omitting the home stop or a bypassed stop.
+ """
+ @spec try_omit_stops(t(), :closure | :gap, pos_integer()) ::
+ {:ok, t()} | {:error, reason :: String.t()}
+ def try_omit_stops(builder, region, target_slots)
+
+ def try_omit_stops(%__MODULE__{} = builder, :closure, target_closure_slots) do
+ try_omit(builder, closure_indices(builder), target_closure_slots)
+ end
+
+ def try_omit_stops(%__MODULE__{} = builder, :gap, target_gap_stops) do
+ try_omit(builder, gap_indices(builder), target_gap_stops)
+ end
+
+ @doc """
+ Moves `num_to_add` stops back from the left/right end groups to the main sequence,
+ effectively "padding" the diagram with stops that otherwise would have been
+ omitted inside one of the destination-arrow slots.
+ Stops are added from the end closest to the home stop, unless it's empty.
+ In that case, they are added from the opposite end.
+ """
+ @spec add_slots(t(), pos_integer()) :: t()
+ def add_slots(%__MODULE__{} = builder, num_to_add) do
+ closure_region_indices = closure_indices(builder)
+
+ home_stop_is_right_of_center = builder.metadata.home_stop > center(closure_region_indices)
+
+ pull_from = if home_stop_is_right_of_center, do: :right_end, else: :left_end
+
+ builder
+ |> do_add_slots(num_to_add, pull_from)
+ |> recalculate_metadata()
+ end
+
+ @doc "Serializes the builder to a DisruptionDiagram.serialized_response()."
+ @spec serialize(t()) :: DD.serialized_response()
+ def serialize(%__MODULE__{} = builder) do
+ builder = add_back_end_slots(builder)
+
+ base_data = %{
+ effect: builder.metadata.effect,
+ line: builder.metadata.line,
+ current_station_slot_index: builder.metadata.home_stop,
+ slots: serialize_sequence(builder)
+ }
+
+ if base_data.effect == :station_closure do
+ Map.put(
+ base_data,
+ :closed_station_slot_indices,
+ disrupted_stop_indices(builder)
+ )
+ else
+ range =
+ builder
+ |> disrupted_stop_indices()
+ |> Enum.min_max()
+
+ Map.put(base_data, :effect_region_slot_index_range, range)
+ end
+ end
+
+ @doc """
+ Returns the number of slots that would be in the diagram produced by the current builder.
+ """
+ @spec slot_count(t()) :: non_neg_integer()
+ def slot_count(%__MODULE__{} = builder) do
+ left_end_slot_count = min(vec_size(builder.left_end), 1)
+ right_end_slot_count = min(vec_size(builder.right_end), 1)
+
+ vec_size(builder.sequence) + left_end_slot_count + right_end_slot_count
+ end
+
+ @doc """
+ Returns the number of stops comprising the closure region of the diagram.
+
+ **This can be different from the number of disrupted stops!**
+
+ For station closures, we count from the stop on the left of the first bypassed stop to the stop on the right of the last bypassed stop:
+ O === O === X === O === X === X === O === O
+ |-----------------------------|
+ count = 6
+
+ For shuttles and suspensions, it's just the stops that are directly informed by the alert:
+ O === O === X - - X - - X - - X === O === O
+ |-----------------|
+ count = 4
+ """
+ @spec closure_count(t()) :: non_neg_integer()
+ def closure_count(%__MODULE__{} = builder) do
+ builder
+ |> closure_indices()
+ |> Enum.count()
+ end
+
+ @doc """
+ Returns the number of stops comprising the gap region of the diagram.
+
+ This is always the stops between the closure region and the home stop.
+ """
+ @spec gap_count(t()) :: non_neg_integer()
+ def gap_count(%__MODULE__{} = builder) do
+ Enum.count(gap_indices(builder))
+ end
+
+ @doc """
+ Returns the number of stops comprising the "current location" region
+ of the diagram.
+
+ This is normally 2: the actual home stop, and its adjacent stop
+ on the far side of the closure. Its adjacent stop on the near side is
+ part of the gap.
+
+ The number is lower when the closure region overlaps with this region,
+ or when the home stop is at/near a terminal.
+ """
+ @spec current_location_count(t()) :: non_neg_integer()
+ def current_location_count(%__MODULE__{} = builder) do
+ builder
+ |> current_location_indices()
+ |> Enum.count()
+ end
+
+ @doc """
+ Returns the number of stops comprising the ends of the diagram.
+
+ This is normally 2, unless another region contains either terminal stop of the line.
+ """
+ @spec end_count(t()) :: non_neg_integer()
+ def end_count(%__MODULE__{} = builder) do
+ min(1, vec_size(builder.left_end)) + min(1, vec_size(builder.right_end))
+ end
+
+ @spec line(t()) :: DD.line()
+ def line(%__MODULE__{} = builder), do: builder.metadata.line
+
+ @spec branch(t()) :: DD.branch()
+ def branch(%__MODULE__{} = builder), do: builder.metadata.branch
+
+ @doc """
+ Returns true if this diagram is
+ - for a Green Line alert,
+ - includes at least one GLX stop (past Lechmere), and
+ - does not extend west of Copley.
+ """
+ @spec glx_only?(t()) :: boolean()
+ def glx_only?(%__MODULE__{} = builder) do
+ is_glx_branch = builder.metadata.branch in [:d, :e]
+
+ diagram_contains_glx =
+ Aja.Enum.any?(builder.sequence, fn
+ %StopSlot{} = stop_data -> Stop.on_glx?(stop_data.id)
+ _ -> false
+ end)
+
+ copley_index =
+ Aja.Enum.find_index(builder.sequence, fn
+ %StopSlot{id: "place-coecl"} -> true
+ _ -> false
+ end)
+
+ no_stops_west_of_copley =
+ case copley_index do
+ nil -> true
+ # If Copley is in the sequence, it can only be the last stop
+ i -> i == vec_size(builder.sequence) - 1
+ end
+
+ is_glx_branch and diagram_contains_glx and no_stops_west_of_copley
+ end
+
+ # Gets all the stuff we need to assemble the struct.
+ @spec get_builder_data(LocalizedAlert.t(), MapSet.t(Stop.id())) ::
+ {:ok, informed_route :: Route.id(), stop_sequence :: list(Stop.id()), DD.branch()}
+ | {:error, String.t()}
+ defp get_builder_data(localized_alert, informed_stop_ids) do
+ stops_in_diagram = MapSet.put(informed_stop_ids, localized_alert.location_context.home_stop)
+
+ matching_tagged_sequences =
+ Enum.flat_map(localized_alert.location_context.tagged_stop_sequences, fn {route, sequences} ->
+ sequences
+ |> Enum.filter(&MapSet.subset?(stops_in_diagram, MapSet.new(&1)))
+ |> Enum.map(&{route, &1})
+ end)
+
+ informed_route_id =
+ Enum.find_value(localized_alert.alert.informed_entities, fn
+ %{route: "Green" <> _ = route_id} -> route_id
+ %{route: route_id} when route_id in ["Blue", "Orange", "Red"] -> route_id
+ _ -> false
+ end)
+
+ do_get_data(matching_tagged_sequences, informed_route_id)
+ end
+
+ defp do_get_data([], _) do
+ {:error, "no stop sequence contains both the home stop and all informed stops"}
+ end
+
+ # A single Green Line branch
+ defp do_get_data([{"Green-" <> branch_letter = route_id, sequence}], _) do
+ branch =
+ branch_letter
+ |> String.downcase()
+ |> String.to_existing_atom()
+
+ {:ok, route_id, sequence, branch}
+ end
+
+ # A single Red Line branch
+ defp do_get_data([{"Red", sequence}], _) do
+ branch = if "place-asmnl" in sequence, do: :ashmont, else: :braintree
+
+ {:ok, "Red", sequence, branch}
+ end
+
+ # A single non-branching route
+ defp do_get_data([{route_id, sequence}], _) do
+ {:ok, route_id, sequence, :trunk}
+ end
+
+ # 2+ routes
+ defp do_get_data(matches, informed_route_id) do
+ cond do
+ Enum.all?(matches, &match?({"Green-" <> _, _}, &1)) ->
+ # Green Line trunk
+ {:ok, "Green", gl_trunk_stop_sequence(), :trunk}
+
+ Enum.all?(matches, &match?({"Red", _}, &1)) ->
+ # Red Line trunk
+ {:ok, "Red", rl_trunk_stop_sequence(), :trunk}
+
+ # The remaining cases are for when 2+ lines contain the stop(s). We defer to informed route.
+ # Only core stops are served by more than one line, so we'll use the trunk sequences for GL/RL.
+ String.starts_with?(informed_route_id, "Green") ->
+ # Green Line trunk, probably at North Station, Haymarket, Government Center, or Park Street
+ {:ok, "Green", gl_trunk_stop_sequence(), :trunk}
+
+ informed_route_id == "Red" ->
+ # Red Line trunk, probably at Park Street or Downtown Crossing
+ {:ok, "Red", rl_trunk_stop_sequence(), :trunk}
+
+ true ->
+ # Orange Line, probably at North Station, Haymarket, State, or Downtown Crossing
+ # or Blue Line, probably at Government Center or State
+ {:ok, informed_route_id, Stop.get_route_stop_sequence(informed_route_id), :trunk}
+ end
+ end
+
+ defp gl_trunk_stop_sequence do
+ Enum.map(Stop.gl_trunk_stops(), fn {stop_id, _labels} -> stop_id end)
+ end
+
+ defp rl_trunk_stop_sequence do
+ Enum.map(Stop.rl_trunk_stops(), fn {stop_id, _labels} -> stop_id end)
+ end
+
+ # Adjusts the left and right ends of the sequence before we split them off into `left_end` and `right_end`.
+ # - Mark terminal stops as such
+ # - For branching ends of trunk sequences (JFK, Lechmere, Kenmore), add `ArrowSlot`s with labels for those branches.
+ defp adjust_ends(sequence, line, branch)
+
+ defp adjust_ends(sequence, :green, :trunk) do
+ # The Green Line trunk (Lechmere to Kenmore) has branches at both ends.
+ sequence
+ |> Vector.prepend(%ArrowSlot{label_id: "place-mdftf+place-unsqu"})
+ |> Vector.append(%ArrowSlot{label_id: "western_branches"})
+ end
+
+ defp adjust_ends(sequence, :red, :trunk) do
+ # The Red Line trunk (Alewife to JFK) has a terminal at Alewife and branches past JFK.
+ sequence
+ |> Vector.update_at!(0, &%{&1 | terminal?: true})
+ |> Vector.append(%ArrowSlot{label_id: "place-asmnl+place-brntn"})
+ end
+
+ defp adjust_ends(sequence, _line, _branch) do
+ # All other stop sequences have terminals at both ends.
+ sequence
+ |> Vector.update_at!(0, &%{&1 | terminal?: true})
+ |> Vector.update_at!(-1, &%{&1 | terminal?: true})
+ end
+
+ # Removes stops outside the closure/current location regions from the main sequence, and puts them into the ends.
+ # O = O = O = O = X = X = X = X = O = O = <> = O = O = O = =>
+ # ^ ^ ^ ^ ^ ^ ^
+ # Moved to left_end Moved to right_end
+ defp split_end_stops(builder) when builder.metadata.line == :blue do
+ # Since we always show all stops for the Blue Line, we don't need to do
+ # anything special with the ends. They don't need to be split out.
+
+ builder
+ end
+
+ defp split_end_stops(builder) do
+ # In all other cases, we split out the left and right ends.
+
+ in_diagram =
+ [
+ closure_indices(builder),
+ gap_indices(builder),
+ # We can save a little work by using the "ideal" indices here, since
+ # any overlap will disappear when we drop these into a MapSet.
+ current_location_ideal_indices(builder)
+ ]
+ |> Enum.concat()
+ |> MapSet.new()
+
+ {leftmost_stop_index, rightmost_stop_index} = Enum.min_max(in_diagram)
+
+ # Example: If the first one we're keeping is at index 5,
+ # then it's the 6th element so we need to slice off the first 5.
+ left_slice_amount = leftmost_stop_index
+
+ last_index = Vector.size(builder.sequence) - 1
+ right_slice_amount = last_index - rightmost_stop_index
+
+ builder
+ |> split_end(:right_end, right_slice_amount)
+ |> split_end(:left_end, left_slice_amount)
+ |> recalculate_metadata()
+ end
+
+ defp split_end(builder, end_field, 0), do: %{builder | end_field => Vector.new()}
+
+ defp split_end(builder, :left_end, amount) do
+ {left_end, sequence} = Vector.split(builder.sequence, amount)
+
+ # (We expect recalculate_metadata to be invoked in the calling function, so don't do it here.)
+ %{builder | sequence: sequence, left_end: left_end}
+ end
+
+ defp split_end(builder, :right_end, amount) do
+ {sequence, right_end} = Vector.split(builder.sequence, -amount)
+
+ %{builder | sequence: sequence, right_end: right_end}
+ end
+
+ # Re-computes index fields (home_stop, first/last_disrupted_stop)
+ # in builder.metadata after builder.sequence is changed.
+ #
+ # This function must be called after any operation that changes builder.sequence.
+ defp recalculate_metadata(builder) do
+ # We're going to replace all of the indices, so throw out the old ones.
+ # That way, if we fail to set one of them (which shouldn't happen),
+ # the `struct!` call below will fail instead of continuing with missing data.
+ meta_without_indices =
+ builder.metadata
+ |> Map.from_struct()
+ |> Map.drop([:home_stop, :first_disrupted_stop, :last_disrupted_stop])
+
+ indexed_sequence = Vector.with_index(builder.sequence)
+
+ home_stop =
+ Aja.Enum.find_value(indexed_sequence, fn
+ {%StopSlot{home_stop?: true}, i} -> i
+ _ -> false
+ end)
+
+ first_disrupted_stop =
+ Aja.Enum.find_value(indexed_sequence, fn
+ {%StopSlot{disrupted?: true}, i} -> i
+ _ -> false
+ end)
+
+ last_disrupted_stop =
+ indexed_sequence
+ |> Vector.reverse()
+ |> Aja.Enum.find_value(fn
+ {%StopSlot{disrupted?: true}, i} -> i
+ _ -> false
+ end)
+
+ new_metadata =
+ meta_without_indices
+ |> Map.merge(%{
+ home_stop: home_stop,
+ first_disrupted_stop: first_disrupted_stop,
+ last_disrupted_stop: last_disrupted_stop
+ })
+ |> then(&struct!(Metadata, &1))
+
+ %{builder | metadata: new_metadata}
+ end
+
+ defp try_omit(builder, current_region_indices, target_slots) do
+ region_length = Enum.count(current_region_indices)
+
+ if target_slots >= region_length do
+ raise "Nothing to omit, function should not have been called"
+ end
+
+ # We need to omit 1 more stop than the difference, to account for the omission itself, which still takes up one slot:
+ #
+ # region: X - - X - - X - - X - - X :: length 5
+ # target_slots: 3
+ #
+ # 5 - 3 + 1 = 3 stops to omit (not 2!)
+ #
+ # after omission: X - - ... - - X :: length 3
+ num_to_omit = region_length - target_slots + 1
+
+ num_to_keep = region_length - num_to_omit
+
+ home_stop_is_right_of_center = builder.metadata.home_stop > center(current_region_indices)
+
+ # If the number of slots to keep is odd, more slots are devoted to the side of the region nearest the home stop.
+ offset =
+ if rem(num_to_keep, 2) == 1 and not home_stop_is_right_of_center do
+ # num_to_keep is odd and the home stop is NOT to the right of the closure center.
+ div(num_to_keep, 2) + 1
+ else
+ # num_to_keep is even, OR num_to_keep is odd and the home stop is to the right of the closure center.
+ div(num_to_keep, 2)
+ end
+
+ omitted_indices =
+ current_region_indices
+ |> Enum.drop(offset)
+ |> Enum.take(num_to_omit)
+ |> Enum.min_max()
+ |> then(fn {leftmost_omitted, rightmost_omitted} ->
+ leftmost_omitted..rightmost_omitted//1
+ end)
+
+ important_indices = get_important_indices(builder)
+
+ undesired_omissions =
+ MapSet.intersection(MapSet.new(omitted_indices), MapSet.new(important_indices))
+
+ if MapSet.size(undesired_omissions) == 0 do
+ {:ok, do_omit(builder, omitted_indices)}
+ else
+ try_alternate_omit(builder, omitted_indices, important_indices)
+ end
+ end
+
+ # Returns a sorted vector containing indices of stops that can't be omitted from the closure region.
+ defp get_important_indices(builder) do
+ closure_first..closure_last//1 = closure = closure_indices(builder)
+
+ [
+ closure_first,
+ closure_last,
+ builder.metadata.home_stop in closure and builder.metadata.home_stop,
+ builder.metadata.effect == :station_closure and disrupted_stop_indices(builder)
+ ]
+ |> Enum.filter(& &1)
+ |> List.flatten()
+ |> Enum.sort()
+ |> Vector.new()
+ end
+
+ defp do_omit(builder, omitted_indices) do
+ label =
+ omitted_indices
+ |> MapSet.new(&builder.sequence[&1].id)
+ |> Label.get_omission_label(builder.metadata.line, builder.metadata.branch)
+
+ {first_omitted, last_omitted} = Enum.min_max(omitted_indices)
+
+ builder
+ |> update_in([Access.key(:sequence)], fn seq ->
+ left_side = Vector.slice(seq, 0..(first_omitted - 1)//1)
+ right_side = Vector.slice(seq, (last_omitted + 1)..-1//1)
+
+ left_side +++ Vector.new([%OmittedSlot{label: label}]) +++ right_side
+ end)
+ |> recalculate_metadata()
+ end
+
+ # Handles rare cases where we can't omit stops from the center of the closure.
+ # - First, it tries to find a segment of "omission-safe" stops to one side of the center, searching from the center outward.
+ # - If there are no segments wide enough, it then tries to do the omission in two places.
+ # - If it's still not possible to reduce the slots to the target amount without omitting
+ # an important stop, it gives up and returns an error tuple.
+ defp try_alternate_omit(builder, original_omission, important_indices) do
+ with :error <- try_side_omit(builder, original_omission, important_indices),
+ :error <- try_split_omit(builder, original_omission, important_indices) do
+ n = Range.size(original_omission)
+ msg = "can't omit #{n} from closure region without omitting at least one important stop"
+
+ {:error, msg}
+ end
+ end
+
+ defp try_side_omit(builder, original_omission, important_indices) do
+ left_try = find_safe_segment(original_omission, important_indices, :left)
+ right_try = find_safe_segment(original_omission, important_indices, :right)
+
+ case {left_try, right_try} do
+ {:error, :error} ->
+ :error
+
+ {{:ok, safe_omission_left, _offset}, :error} ->
+ {:ok, do_omit(builder, safe_omission_left)}
+
+ {:error, {:ok, safe_omission_right, _offset}} ->
+ {:ok, do_omit(builder, safe_omission_right)}
+
+ both_safe ->
+ both_safe
+ |> Tuple.to_list()
+ |> Enum.min_by(fn {:ok, _omission, offset} -> offset end)
+ |> then(fn {:ok, safe_omission, _offset} -> {:ok, do_omit(builder, safe_omission)} end)
+ end
+ end
+
+ defp try_split_omit(builder, original_omission, important_indices) do
+ # A second omission means a second label--
+ # we need to omit one additional stop to still reach the target region length.
+ omit_count = 1 + Range.size(original_omission)
+
+ closure_first = Vector.first(important_indices)
+ closure_last = Vector.last(important_indices)
+
+ center_index = center(closure_first..closure_last//1)
+
+ # Find all safe segments, sort the longest ones first, and split those to the left
+ # of the closure center from those to the right.
+ {left_segments, right_segments} =
+ important_indices
+ |> Enum.chunk_every(2, 1, :discard)
+ |> Enum.map(fn [left_important, right_important] ->
+ (left_important + 1)..(right_important - 1)//1
+ end)
+ |> Enum.reject(&(Range.size(&1) == 0))
+ |> Enum.sort_by(&Range.size/1, :desc)
+ |> Enum.split_with(&(center(&1) <= center_index))
+
+ left1 = Enum.at(left_segments, 0, ..)
+ left2 = Enum.at(left_segments, 1, ..)
+
+ right1 = Enum.at(right_segments, 0, ..)
+ right2 = Enum.at(right_segments, 1, ..)
+
+ # First, try to omit from either side of the center.
+ # If that's not possible, try omitting in two different places to one side of the center.
+ # After that, give up!
+ segment_pair =
+ cond do
+ Range.size(left1) + Range.size(right1) >= omit_count ->
+ {Enum.reverse(left1), Enum.to_list(right1)}
+
+ Range.size(left1) + Range.size(left2) >= omit_count ->
+ {Enum.reverse(left1), Enum.reverse(left2)}
+
+ Range.size(right1) + Range.size(right2) >= omit_count ->
+ {Enum.to_list(right1), Enum.to_list(right2)}
+
+ true ->
+ :error
+ end
+
+ with {_segment1, _segment2} <- segment_pair do
+ {left_omission, right_omission} = select_split_omission_indices(segment_pair, omit_count)
+
+ # We *must* do the right omission before the left, to avoid having the indices change underneath us.
+ builder =
+ builder
+ |> do_omit(right_omission)
+ |> do_omit(left_omission)
+
+ {:ok, builder}
+ end
+ end
+
+ # Evenly pulls indices from the left and right segments until acc contains enough indices.
+ defp select_split_omission_indices(segment_pair, omit_count, l_acc \\ [], r_acc \\ [])
+
+ defp select_split_omission_indices({l, r}, omit_count, l_acc, r_acc) do
+ select_split_omission_indices(l, r, omit_count, l_acc, r_acc)
+ end
+
+ defp select_split_omission_indices(_l, _r, 0, l_acc, r_acc), do: {l_acc, r_acc}
+
+ defp select_split_omission_indices([], [h | t], n, l_acc, r_acc) do
+ select_split_omission_indices([], t, n - 1, l_acc, [h | r_acc])
+ end
+
+ defp select_split_omission_indices([h | t], [], n, l_acc, r_acc) do
+ select_split_omission_indices(t, [], n - 1, [h | l_acc], r_acc)
+ end
+
+ defp select_split_omission_indices([h | t], r, n, l_acc, r_acc)
+ when length(l_acc) <= length(r_acc) do
+ select_split_omission_indices(t, r, n - 1, [h | l_acc], r_acc)
+ end
+
+ defp select_split_omission_indices(l, [h | t], n, l_acc, r_acc) do
+ select_split_omission_indices(l, t, n - 1, l_acc, [h | r_acc])
+ end
+
+ # Searches for a contiguous segment of stops, none of which are important, which
+ # we can omit from the diagram.
+ #
+ # The search starts from the original desired omission near the center of the region
+ # and moves outward, either left or right depending on the `side` argument,
+ # returning either {:ok, safe_segment} or :error if none is found.
+ defp find_safe_segment(original_omission, important_indices, side, offset \\ 1)
+
+ defp find_safe_segment(original_omission, important_indices, :left, offset) do
+ _l..r//1 = original_omission
+
+ tl..tr//1 = tentative_omission = Range.shift(original_omission, -offset)
+
+ if tl <= Vector.first(important_indices) or tr >= Vector.last(important_indices) do
+ :error
+ else
+ first_overlap =
+ important_indices
+ |> Vector.reverse()
+ |> Aja.Enum.find(&(&1 in tentative_omission))
+
+ case first_overlap do
+ nil ->
+ {:ok, tentative_omission, offset}
+
+ i ->
+ # The tentative window contains an important index. Move the window past the first important index and try again.
+ find_safe_segment(original_omission, important_indices, :left, 1 + r - i)
+ end
+ end
+ end
+
+ defp find_safe_segment(original_omission, important_indices, :right, offset) do
+ l.._r//1 = original_omission
+
+ tl..tr//1 = tentative_omission = Range.shift(original_omission, offset)
+
+ if tl <= Vector.first(important_indices) or tr >= Vector.last(important_indices) do
+ :error
+ else
+ first_overlap = Aja.Enum.find(important_indices, &(&1 in tentative_omission))
+
+ case first_overlap do
+ nil ->
+ {:ok, tentative_omission, offset}
+
+ i ->
+ # The tentative window contains an important index. Move the window past the first important index and try again.
+ find_safe_segment(original_omission, important_indices, :right, 1 + i - l)
+ end
+ end
+ end
+
+ defp do_add_slots(builder, 0, _), do: builder
+
+ defp do_add_slots(builder, _greater_than_0, _)
+ when vec_size(builder.left_end) == 0 and vec_size(builder.right_end) == 0 do
+ # There are no more end stops available on either side.
+ # This code is probably running in a test case if the stop sequence is that small.
+ # Just return the builder.
+ builder
+ end
+
+ defp do_add_slots(builder, num_to_add, :left_end)
+ when vec_size(builder.left_end) == 0 do
+ do_add_slots(builder, num_to_add, :right_end)
+ end
+
+ defp do_add_slots(builder, num_to_add, :right_end)
+ when vec_size(builder.right_end) == 0 do
+ do_add_slots(builder, num_to_add, :left_end)
+ end
+
+ defp do_add_slots(builder, num_to_add, :right_end) do
+ {stop_data, new_right_end} = Vector.pop_at(builder.right_end, 0)
+
+ # If we just added the last slot from the right end, all we did was move
+ # a terminal/arrow back into the main sequence.
+ # Effectively, nothing was added to the diagram.
+ new_num_to_add = if vec_size(new_right_end) > 0, do: num_to_add - 1, else: num_to_add
+
+ builder
+ |> put_in([Access.key(:right_end)], new_right_end)
+ |> update_in([Access.key(:sequence)], &Vector.append(&1, stop_data))
+ |> do_add_slots(new_num_to_add, :right_end)
+ end
+
+ defp do_add_slots(builder, num_to_add, :left_end) do
+ {stop_data, new_left_end} = Vector.pop_last!(builder.left_end)
+
+ # If we just added the last slot from the left end, all we did was move
+ # a terminal/arrow back into the main sequence.
+ # Effectively, nothing was added to the diagram.
+ new_num_to_add = if vec_size(new_left_end) > 0, do: num_to_add - 1, else: num_to_add
+
+ builder
+ |> put_in([Access.key(:left_end)], new_left_end)
+ |> update_in([Access.key(:sequence)], &Vector.prepend(&1, stop_data))
+ |> do_add_slots(new_num_to_add, :left_end)
+ end
+
+ defp serialize_sequence(%__MODULE__{} = builder) do
+ Aja.Enum.map(builder.sequence, fn
+ %ArrowSlot{} = arrow -> %{type: :arrow, label_id: arrow.label_id}
+ %StopSlot{} = stop when stop.terminal? -> %{type: :terminal, label_id: stop.id}
+ %StopSlot{} = stop -> %{label: stop.label, show_symbol: true}
+ %OmittedSlot{} = omitted -> %{label: omitted.label, show_symbol: false}
+ end)
+ end
+
+ # Re-adds each of left_end and right_end to the main sequence as either:
+ # - a terminal stop slot if the end contains 1 stop,
+ # - a destination-arrow slot if the end contains multiple stops, or
+ # - nothing if the end contains no stops.
+ defp add_back_end_slots(builder) do
+ left_end = get_end_slot(builder.metadata, builder.left_end)
+ right_end = get_end_slot(builder.metadata, builder.right_end)
+
+ %{builder | sequence: left_end +++ builder.sequence +++ right_end}
+ |> recalculate_metadata()
+ end
+
+ defp get_end_slot(_meta, vec([])), do: Vector.new()
+
+ defp get_end_slot(_meta, vec([%{terminal?: true} = stop_data])), do: Vector.new([stop_data])
+
+ defp get_end_slot(_meta, vec([%ArrowSlot{} = predefined_destination])),
+ do: Vector.new([predefined_destination])
+
+ defp get_end_slot(meta, stops) do
+ stop_ids =
+ stops
+ |> Vector.filter(&is_struct(&1, StopSlot))
+ |> MapSet.new(& &1.id)
+
+ label_id = Label.get_end_label_id(stop_ids, meta.line, meta.branch)
+
+ Vector.new([%ArrowSlot{label_id: label_id}])
+ end
+
+ # Returns a sorted list of indices of the stops that are in the alert's informed entities.
+ # For station closures, this is the stops that are bypassed.
+ # For shuttles and suspensions, this is the stops that don't have any train service
+ # *as well as* the stops at the boundary of the disruption that don't have train service in one direction.
+ defp disrupted_stop_indices(%__MODULE__{} = builder) do
+ builder.sequence
+ |> Vector.with_index()
+ |> Vector.filter(fn
+ {%StopSlot{} = stop_data, _i} -> stop_data.disrupted?
+ {_other_slot_type, _i} -> false
+ end)
+ |> Aja.Enum.map(fn {_stop_data, i} -> i end)
+ end
+
+ # The closure has highest priority, so no other overlapping region can take stops from it.
+ defp closure_indices(%{metadata: %{effect: :station_closure}} = builder) do
+ # first = One stop before the first bypassed stop, if it exists. Otherwise, the first bypassed stop.
+ first = clamp(builder.metadata.first_disrupted_stop - 1, vec_size(builder.sequence))
+
+ # last = One stop past the last bypassed stop, if it exists. Otherwise, the last bypassed stop.
+ last = clamp(builder.metadata.last_disrupted_stop + 1, vec_size(builder.sequence))
+
+ first..last//1
+ end
+
+ defp closure_indices(%{metadata: %{effect: continuous} = metadata})
+ when continuous in [:shuttle, :suspension] do
+ metadata.first_disrupted_stop..metadata.last_disrupted_stop//1
+ end
+
+ # The gap region has second highest priority and by its definition doesn't overlap with the closure region.
+ defp gap_indices(builder) do
+ home_stop = builder.metadata.home_stop
+
+ closure_left..closure_right = closure_indices(builder)
+
+ cond do
+ home_stop < closure_left -> (home_stop + 1)..(closure_left - 1)//1
+ home_stop > closure_right -> (closure_right + 1)..(home_stop - 1)//1
+ true -> ..
+ end
+ end
+
+ # The current location region can be subsumed by the closure and the gap regions.
+ defp current_location_indices(builder) do
+ current_location_region = MapSet.new(current_location_ideal_indices(builder))
+
+ gap_region = MapSet.new(gap_indices(builder))
+ closure_region = MapSet.new(closure_indices(builder))
+
+ current_location_region
+ |> MapSet.difference(MapSet.union(gap_region, closure_region))
+ |> Enum.min_max(fn -> :its_empty end)
+ |> case do
+ {left, right} -> left..right//1
+ :its_empty -> ..
+ end
+ end
+
+ # Indices of the current location region if none were taken by other higher-precedence regions.
+ defp current_location_ideal_indices(builder) do
+ home_stop = builder.metadata.home_stop
+
+ size = vec_size(builder.sequence)
+
+ clamp(home_stop - 1, size)..clamp(home_stop + 1, size)//1
+ end
+
+ # (Just left of center if length is even.)
+ defp center(l..r//1) when r >= l do
+ l + div(r - l, 2)
+ end
+
+ # Adjusts an index to be within the bounds of the stop sequence.
+ defp clamp(index, _sequence_size) when index < 0, do: 0
+ defp clamp(index, sequence_size) when index >= sequence_size, do: sequence_size - 1
+ defp clamp(index, _sequence_size), do: index
+end
diff --git a/lib/screens/v2/disruption_diagram/label.ex b/lib/screens/v2/disruption_diagram/label.ex
new file mode 100644
index 000000000..7551ecc74
--- /dev/null
+++ b/lib/screens/v2/disruption_diagram/label.ex
@@ -0,0 +1,112 @@
+defmodule Screens.V2.DisruptionDiagram.Label do
+ @moduledoc """
+ Functions for labeling disruption diagram slots.
+ """
+
+ alias Screens.Stops.Stop
+ alias Screens.V2.DisruptionDiagram, as: DD
+
+ @doc "Returns the label for an omitted slot."
+ @spec get_omission_label(MapSet.t(Stop.id()), DD.line(), DD.branch()) :: DD.label()
+ def get_omission_label(omitted_stop_ids, :green, branch_thru_kenmore)
+ when branch_thru_kenmore in [:b, :c, :d] do
+ # For GL branches that pass through Kenmore, we look for Kenmore and Copley.
+ [
+ "place-kencl" in omitted_stop_ids and "Kenmore",
+ "place-coecl" in omitted_stop_ids and "Copley"
+ ]
+ |> Enum.filter(& &1)
+ |> Enum.join(" & ")
+ |> case do
+ "" -> "…"
+ stop_names -> %{full: "…via #{stop_names}", abbrev: "…via #{stop_names}"}
+ end
+ end
+
+ def get_omission_label(omitted_stop_ids, :green, _trunk_or_e_branch) do
+ # For E branch and trunk, we look for Government Center only.
+ if "place-gover" in omitted_stop_ids,
+ do: %{full: "…via Government Center", abbrev: "…via Gov't Ctr"},
+ else: "…"
+ end
+
+ # Orange and Red Lines both only look for Downtown Crossing.
+ def get_omission_label(omitted_stop_ids, line, _) when line in [:orange, :red] do
+ if "place-dwnxg" in omitted_stop_ids,
+ do: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ else: "…"
+ end
+
+ @doc "Returns the label ID for an end that contains more than one item."
+ @spec get_end_label_id(MapSet.t(Stop.id()), DD.line(), DD.branch()) :: DD.end_label_id()
+ def get_end_label_id(end_stop_ids, :orange, _) do
+ cond do
+ "place-forhl" in end_stop_ids -> "place-forhl"
+ "place-ogmnl" in end_stop_ids -> "place-ogmnl"
+ end
+ end
+
+ def get_end_label_id(end_stop_ids, :red, :trunk) do
+ cond do
+ "place-alfcl" in end_stop_ids -> "place-alfcl"
+ "place-jfk" in end_stop_ids -> "place-asmnl+place-brntn"
+ end
+ end
+
+ def get_end_label_id(end_stop_ids, :red, :ashmont) do
+ cond do
+ "place-alfcl" in end_stop_ids -> "place-alfcl"
+ "place-asmnl" in end_stop_ids -> "place-asmnl"
+ end
+ end
+
+ def get_end_label_id(end_stop_ids, :red, :braintree) do
+ cond do
+ "place-alfcl" in end_stop_ids -> "place-alfcl"
+ "place-brntn" in end_stop_ids -> "place-brntn"
+ end
+ end
+
+ def get_end_label_id(end_stop_ids, :green, :trunk) do
+ cond do
+ # left end
+ "place-lech" in end_stop_ids -> "place-mdftf+place-unsqu"
+ # right end
+ # vvv
+ "place-north" in end_stop_ids -> "place-north+place-pktrm"
+ "place-gover" in end_stop_ids -> "place-gover"
+ # ^^^ These two labels are not possible to produce.
+ # Diagrams for trunk alerts not extending past these stops are too small and will be padded to include them.
+ "place-coecl" in end_stop_ids -> "place-coecl+west"
+ "place-kencl" in end_stop_ids -> "place-kencl+west"
+ end
+ end
+
+ def get_end_label_id(end_stop_ids, :green, :b) do
+ cond do
+ "place-gover" in end_stop_ids -> "place-gover"
+ "place-lake" in end_stop_ids -> "place-lake"
+ end
+ end
+
+ def get_end_label_id(end_stop_ids, :green, :c) do
+ cond do
+ "place-gover" in end_stop_ids -> "place-gover"
+ "place-clmnl" in end_stop_ids -> "place-clmnl"
+ end
+ end
+
+ def get_end_label_id(end_stop_ids, :green, :d) do
+ cond do
+ "place-unsqu" in end_stop_ids -> "place-unsqu"
+ "place-river" in end_stop_ids -> "place-river"
+ end
+ end
+
+ def get_end_label_id(end_stop_ids, :green, :e) do
+ cond do
+ "place-mdftf" in end_stop_ids -> "place-mdftf"
+ "place-hsmnl" in end_stop_ids -> "place-hsmnl"
+ end
+ end
+end
diff --git a/lib/screens/v2/disruption_diagram/model.ex b/lib/screens/v2/disruption_diagram/model.ex
index 090cd92cb..11613896d 100644
--- a/lib/screens/v2/disruption_diagram/model.ex
+++ b/lib/screens/v2/disruption_diagram/model.ex
@@ -1,81 +1,197 @@
defmodule Screens.V2.DisruptionDiagram.Model do
@moduledoc """
- Struct and functions to generate and model a disruption diagram.
+ Functions to generate a disruption diagram from a `LocalizedAlert`.
+
+ Most of the logic is focused on fitting content into at most 14 slots by omitting stops from the Closure, the Gap, and/or the
+ Ends as necessary.
+
+ The logic reflects the flowchart created by Betsy and viewable [here](https://miro.com/app/board/uXjVP2Hgi18=/).
+
+ # 📕 Terminology
+
+ | Term | Definition |
+ | :- | :- |
+ | Slot | A single, labeled "point" on the diagram. Can be a stop, an omitted segment, a terminal stop, or a destination arrow. Slots do not necessarily correspond 1:1 with stops. |
+ | Region | A group of slots forming one part of the diagram. Regions can overlap or subsume one another, with a consistent order of precedence: Closure > Gap > Current Location > Ends. |
+ | Closure | The region containing disrupted stops. For station closures, the non-disrupted stops on either end of the disrupted area are also included. |
+ | Current Location | The region containing this screen's home stop, as well as the stop(s) on either side of it. |
+ | Gap | The region between the Closure and the screen's home stop. When present, the Gap always takes the Current Location stop closest to the Closure. |
+ | Ends | The up-to 2 slots at either end of the diagram. These can take the form of either terminal stops, or destination arrows. |
"""
- # Model fields TBD
- defstruct []
-
- @type t :: %__MODULE__{}
-
- @type serialized_response :: continuous_disruption_diagram() | discrete_disruption_diagram()
-
- @type continuous_disruption_diagram :: %{
- effect: :shuttle | :suspension,
- # A 2-element list, giving indices of the effect region's *boundary stops*, inclusive.
- # For example in this scenario:
- # 0 1 2 3 4 5 6 7 8
- # <= === O ========= O - - X - - X - - X - - O === O
- # |---------range---------|
- # The range is [3, 7].
- #
- # SPECIAL CASE:
- # If the range starts at 0 or ends at the last element of the array,
- # then the symbol for that terminal stop should use the appropriate
- # disruption symbol, not the "normal service" symbol.
- # For example if the range is [0, 5], the left end of the
- # diagram should use a disruption symbol:
- # 0 1 2 3 4 5 6 7 8
- # X - - X - - X - - X - - X - - O ========= O === =>
- # |------------range------------|
- effect_region_slot_index_range: list(non_neg_integer()),
- line: line_color(),
- current_station_slot_index: non_neg_integer(),
- # First and last elements of the list are `end_slot`s, middle elements are `middle_slot`s.
- slots: list(slot())
- }
-
- @type discrete_disruption_diagram :: %{
- effect: :station_closure,
- closed_station_slot_indices: list(non_neg_integer()),
- line: line_color(),
- current_station_slot_index: non_neg_integer(),
- # First and last elements of the list are `end_slot`s, middle elements are `middle_slot`s.
- slots: list(slot())
- }
-
- @type slot :: end_slot() | middle_slot()
-
- @type end_slot :: %{
- type: :arrow | :terminal,
- label_id: end_label_id()
- }
-
- @type middle_slot :: %{
- label: label(),
- show_symbol: boolean()
- }
-
- @type label :: ellipsis() | %{full: String.t(), abbrev: String.t()}
-
- # Literally the string "…", but you can't use string literals as types in elixir
- @type ellipsis :: String.t()
-
- # End labels have hardcoded presentation, so we just send an ID for the client to use in
- # a lookup.
+ alias Screens.V2.DisruptionDiagram, as: DD
+ alias Screens.V2.DisruptionDiagram.Builder, as: B
+ alias Screens.V2.DisruptionDiagram.Validator
+ alias Screens.V2.LocalizedAlert
+
+ import LocalizedAlert, only: [is_localized_alert: 1]
+
+ # If the diagram is shorter than 6 slots, we "pad" it until it contains at least 6.
+ @minimum_slot_count 6
+
+ # If the closure is longer than 8 stops, it needs to be collapsed.
+ @max_closure_count 8
+
+ # When the closure needs to be collapsed, we omit stops
+ # from it until the diagram contains 12 slots total.
+ @max_count_with_collapsed_closure 12
+
+ # When the closure needs to be collapsed, we automatically
+ # also collapse the gap, making it take 2 slots or fewer.
+ @max_collapsed_gap_count 2
+
+ # If everything else fits, we still limit the gap to 3 slots or fewer.
+ @max_gap_count 3
+
+ @doc "Produces a JSON-serializable map representing the disruption diagram."
+ @spec serialize(DD.t()) :: {:ok, DD.serialized_response()} | {:error, reason :: String.t()}
+ def serialize(localized_alert) when is_localized_alert(localized_alert) do
+ with :ok <- Validator.validate(localized_alert) do
+ do_serialize(localized_alert)
+ end
+ rescue
+ error ->
+ error_string =
+ Exception.message(error) <> "\n\n" <> Exception.format_stacktrace(__STACKTRACE__)
+
+ {:error, "Exception raised during serialization:\n\n#{error_string}"}
+ end
+
+ defp do_serialize(localized_alert) do
+ with {:ok, builder} <- B.new(localized_alert) do
+ line = B.line(builder)
+
+ serialize_by_line(line, builder)
+ end
+ end
+
+ @spec serialize_by_line(DD.line(), B.t()) ::
+ {:ok, DD.serialized_response()} | {:error, reason :: String.t()}
+ # The Blue Line is the simplest case. We always show all stops, starting with Bowdoin.
+ defp serialize_by_line(:blue, builder) do
+ # The default stop sequence starts with Wonderland, so we need to put the stops in reverse order
+ # to have Bowdoin appear first on the diagram.
+ builder
+ |> B.reverse()
+ |> B.serialize()
+ |> then(&{:ok, &1})
+ end
+
+ # For the Green Line, we need to reverse the diagram in certain cases, as well as fit regions.
+ defp serialize_by_line(:green, builder) do
+ builder = maybe_reverse_gl(builder)
+
+ with {:ok, builder} <- fit_regions(builder) do
+ {:ok, B.serialize(builder)}
+ end
+ end
+
+ # Red Line and Orange Line diagrams never need to be reversed--we just need to fit regions.
+ defp serialize_by_line(_orange_or_red, builder) do
+ with {:ok, builder} <- fit_regions(builder) do
+ {:ok, B.serialize(builder)}
+ end
+ end
+
+ # For GL, OL, and RL, it's possible for the stops we need to show in the diagram to span more than the maximum
+ # number of slots (14). This function replaces segments of stops with single "omitted" slots in
+ # order to keep the diagram small enough.
#
- # TBD what these IDs will look like. We might just use parent station IDs.
+ # In rare cases, the number of stops to show is too *small* and would look awkward, so we instead pad the diagram with
+ # additional slots, pulling stops in from either side of the disrupted area.
#
- # The rest of the labels' presentations are computed based on the height of the end labels,
- # so we can send actual text for those--it will be dynamically resized to fit.
- @type end_label_id :: String.t()
+ # The fitting process stops after any one of the 3 functions in the `with` expression--`fit_closure_region`, `fit_gap_region`, or
+ # `pad_slots`--makes a change to the diagram.
+ defp fit_regions(builder) do
+ with :unchanged <- fit_closure_region(builder),
+ :unchanged <- fit_gap_region(builder),
+ :unchanged <- pad_slots(builder) do
+ {:ok, builder}
+ else
+ {:done, builder} -> {:ok, builder}
+ {:error, _} = error_result -> error_result
+ end
+ end
+
+ # The diagram needs to be flipped whenever it's not a GLX-only alert.
+ defp maybe_reverse_gl(builder) do
+ if B.glx_only?(builder) do
+ builder
+ else
+ B.reverse(builder)
+ end
+ end
- @type line_color :: :blue | :orange | :red | :green
+ defp fit_closure_region(builder) do
+ current_closure_count = B.closure_count(builder)
+ target_closure_count = @max_count_with_collapsed_closure - min_non_closure_slots(builder)
- @doc "Produces a JSON-serializable map representing the disruption diagram."
- # Update spec when this gets implemented!
- @spec serialize(t()) :: nil
- def serialize(_model) do
- nil
+ if current_closure_count > @max_closure_count and target_closure_count < current_closure_count do
+ with {:ok, builder} <- B.try_omit_stops(builder, :closure, target_closure_count) do
+ {:done, minimize_gap(builder)}
+ end
+ else
+ :unchanged
+ end
+ end
+
+ defp minimize_gap(builder) do
+ current_gap_count = B.gap_count(builder)
+ target_gap_count = min_gap(builder)
+
+ if target_gap_count < current_gap_count do
+ # The gap never contains important stops, so `try_omit_stops` will always succeed.
+ {:ok, builder} = B.try_omit_stops(builder, :gap, target_gap_count)
+ builder
+ else
+ builder
+ end
+ end
+
+ defp fit_gap_region(builder) do
+ current_gap_count = B.gap_count(builder)
+ closure_count = B.closure_count(builder)
+ target_gap_slots = baseline_slots(closure_count) - non_gap_slots(builder)
+
+ if current_gap_count >= @max_gap_count and target_gap_slots < current_gap_count do
+ # The gap never contains important stops, so `try_omit_stops` will always succeed.
+ {:ok, builder} = B.try_omit_stops(builder, :gap, target_gap_slots)
+
+ {:done, builder}
+ else
+ :unchanged
+ end
end
+
+ defp pad_slots(builder) do
+ current_slot_count = B.slot_count(builder)
+
+ if current_slot_count < @minimum_slot_count do
+ {:done, B.add_slots(builder, @minimum_slot_count - current_slot_count)}
+ else
+ :unchanged
+ end
+ end
+
+ defp min_non_closure_slots(builder) do
+ B.end_count(builder) + B.current_location_count(builder) + min_gap(builder)
+ end
+
+ # Number of slots used by all regions except the gap, when it doesn't get minimized.
+ defp non_gap_slots(builder) do
+ B.end_count(builder) + B.closure_count(builder) + B.current_location_count(builder)
+ end
+
+ # The minimum possible size of the gap region.
+ defp min_gap(builder) do
+ min(B.gap_count(builder), @max_collapsed_gap_count)
+ end
+
+ for {closure, baseline} <- %{2 => 10, 3 => 10, 4 => 12, 5 => 12, 6 => 14, 7 => 14, 8 => 14} do
+ defp baseline_slots(unquote(closure)), do: unquote(baseline)
+ end
+
+ # In rare cases when the home stop is inside the closure region,
+ # more than 8 slots are available to the closure.
+ defp baseline_slots(closure) when closure > 8, do: 14
end
diff --git a/lib/screens/v2/disruption_diagram/validator.ex b/lib/screens/v2/disruption_diagram/validator.ex
new file mode 100644
index 000000000..346339a3b
--- /dev/null
+++ b/lib/screens/v2/disruption_diagram/validator.ex
@@ -0,0 +1,69 @@
+defmodule Screens.V2.DisruptionDiagram.Validator do
+ @moduledoc """
+ Validates LocalizedAlerts for compatibility with disruption diagrams:
+ - The alert is a subway alert with an effect of shuttle, suspension, or station_closure.
+ - The alert does not inform an entire route.
+ - If the alert is a shuttle or suspension, it informs at least 2 stops.
+ - All stops informed by the alert are reachable from the home stop without any transfers.
+ - in other words, the alert informs stops on only one subway route.
+ """
+
+ alias Screens.Alerts.Alert
+ alias Screens.Alerts.InformedEntity
+ alias Screens.V2.LocalizedAlert
+
+ @spec validate(LocalizedAlert.t()) :: :ok | {:error, reason :: String.t()}
+ def validate(localized_alert) do
+ with :ok <- validate_effect(localized_alert.alert.effect),
+ :ok <- validate_not_whole_route_disruption(localized_alert.alert),
+ :ok <- validate_stop_count(localized_alert.alert) do
+ validate_informed_lines(localized_alert)
+ end
+ end
+
+ defp validate_effect(effect) when effect in [:shuttle, :suspension, :station_closure], do: :ok
+ defp validate_effect(effect), do: {:error, "invalid effect: #{effect}"}
+
+ defp validate_stop_count(%{effect: continuous_effect} = alert)
+ when continuous_effect in [:shuttle, :suspension] do
+ informed_stops =
+ for %{stop: stop, route: route} <- alert.informed_entities,
+ match?("place-" <> _, stop),
+ route in ~w[Blue Orange Red Green-B Green-C Green-D Green-E],
+ uniq: true,
+ do: stop
+
+ if length(informed_stops) >= 2 do
+ :ok
+ else
+ {:error, "#{continuous_effect} alert does not inform at least 2 stops"}
+ end
+ end
+
+ defp validate_stop_count(_), do: :ok
+
+ defp validate_informed_lines(localized_alert) do
+ localized_alert.alert
+ |> Alert.informed_subway_routes()
+ |> consolidate_gl()
+ |> case do
+ [_single_line] -> :ok
+ _ -> {:error, "alert does not inform exactly one subway line"}
+ end
+ end
+
+ defp validate_not_whole_route_disruption(alert) do
+ if Enum.any?(alert.informed_entities, &InformedEntity.whole_route?/1),
+ do: {:error, "alert informs an entire route"},
+ else: :ok
+ end
+
+ defp consolidate_gl(route_ids) do
+ route_ids
+ |> Enum.map(fn
+ "Green" <> _ -> "Green"
+ other -> other
+ end)
+ |> Enum.uniq()
+ end
+end
diff --git a/lib/screens/v2/localized_alert.ex b/lib/screens/v2/localized_alert.ex
index 8457b74c9..2fb283108 100644
--- a/lib/screens/v2/localized_alert.ex
+++ b/lib/screens/v2/localized_alert.ex
@@ -10,13 +10,12 @@ defmodule Screens.V2.LocalizedAlert do
alias Screens.RouteType
alias Screens.Util
alias Screens.V2.WidgetInstance.Alert, as: AlertWidget
- alias Screens.V2.WidgetInstance.{DupAlert, ElevatorStatus, ReconstructedAlert}
+ alias Screens.V2.WidgetInstance.{DupAlert, ReconstructedAlert}
@type t ::
AlertWidget.t()
| DupAlert.t()
| ReconstructedAlert.t()
- | ElevatorStatus.t()
| %{
optional(:screen) => Screen.t(),
alert: Alert.t(),
@@ -48,6 +47,11 @@ defmodule Screens.V2.LocalizedAlert do
"""
@type headsign :: String.t() | {:adj, String.t()}
+ defguard is_localized_alert(value)
+ when is_map(value) and
+ is_struct(value.alert, Alert) and
+ is_struct(value.location_context, LocationContext)
+
@doc """
Determines the headsign of the affected direction of an alert using
stop IDs in its informed entities.
@@ -202,18 +206,13 @@ defmodule Screens.V2.LocalizedAlert do
@doc """
Returns all routes affected by an alert.
- Used to build route pills for GL e-ink and text for Pre-fare alerts
+ Green Line route consolidation logic differs by screen type.
+ Used to build route pills for GL e-ink and text for Pre-fare alerts.
"""
- @spec informed_subway_routes(t()) :: list(String.t())
- def informed_subway_routes(%{screen: %Screen{app_id: app_id}, alert: alert}) do
+ @spec consolidated_informed_subway_routes(t()) :: list(String.t())
+ def consolidated_informed_subway_routes(%{screen: %Screen{app_id: app_id}, alert: alert}) do
alert
- |> Alert.informed_entities()
- |> Enum.map(fn %{route: route} -> route end)
- # If the alert impacts CR or other lines, weed that out
- |> Enum.filter(fn e ->
- Enum.member?(["Red", "Orange", "Green", "Blue"] ++ @green_line_branches, e)
- end)
- |> Enum.uniq()
+ |> Alert.informed_subway_routes()
|> consolidate_gl(app_id)
end
diff --git a/lib/screens/v2/location_context.ex b/lib/screens/v2/location_context.ex
index e0e4bebe0..bf5202277 100644
--- a/lib/screens/v2/location_context.ex
+++ b/lib/screens/v2/location_context.ex
@@ -22,7 +22,9 @@ defmodule Screens.LocationContext do
tagged_stop_sequences: %{Route.id() => list(list(Stop.id()))},
upstream_stops: MapSet.t(Stop.id()),
downstream_stops: MapSet.t(Stop.id()),
+ # Routes serving this stop
routes: list(%{route_id: Route.id(), active?: boolean()}),
+ # Route types we care about for the alerts of this screen type / place
alert_route_types: list(RouteType.t())
}
diff --git a/lib/screens/v2/widget_instance/alert.ex b/lib/screens/v2/widget_instance/alert.ex
index 80a40a81e..6185c5894 100644
--- a/lib/screens/v2/widget_instance/alert.ex
+++ b/lib/screens/v2/widget_instance/alert.ex
@@ -103,7 +103,7 @@ defmodule Screens.V2.WidgetInstance.Alert do
routes =
if app_id === :gl_eink_v2 do
# Get route pills for alert, including that on connecting GL branches
- LocalizedAlert.informed_subway_routes(t)
+ LocalizedAlert.consolidated_informed_subway_routes(t)
else
# Get route pills for an alert, but only the routes that are at this stop
LocalizedAlert.informed_routes_at_home_stop(t)
diff --git a/lib/screens/v2/widget_instance/reconstructed_alert.ex b/lib/screens/v2/widget_instance/reconstructed_alert.ex
index 0d7cc8798..7e48ed747 100644
--- a/lib/screens/v2/widget_instance/reconstructed_alert.ex
+++ b/lib/screens/v2/widget_instance/reconstructed_alert.ex
@@ -6,10 +6,13 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
alias Screens.LocationContext
alias Screens.Stops.Stop
alias Screens.Util
+ alias Screens.V2.DisruptionDiagram
alias Screens.V2.LocalizedAlert
alias Screens.V2.WidgetInstance.ReconstructedAlert
alias Screens.V2.WidgetInstance.Serializer.RoutePill
+ require Logger
+
defstruct screen: nil,
alert: nil,
now: nil,
@@ -163,7 +166,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
defp get_route_pills(t, location \\ nil)
defp get_route_pills(t, nil) do
- affected_routes = LocalizedAlert.informed_subway_routes(t)
+ affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t)
affected_routes
|> Enum.group_by(&get_line/1)
@@ -272,7 +275,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
informed_entities = Alert.informed_entities(alert)
route_id =
- case LocalizedAlert.informed_subway_routes(t) do
+ case LocalizedAlert.consolidated_informed_subway_routes(t) do
["Green" <> _] -> "Green"
[route_id] -> route_id
end
@@ -296,7 +299,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
informed_entities = Alert.informed_entities(alert)
route_id =
- case LocalizedAlert.informed_subway_routes(t) do
+ case LocalizedAlert.consolidated_informed_subway_routes(t) do
["Green" <> _] -> "Green"
[route_id] -> route_id
end
@@ -327,7 +330,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
informed_stations_string = Util.format_name_list_to_string(informed_stations)
location_text =
- case LocalizedAlert.informed_subway_routes(t) do
+ case LocalizedAlert.consolidated_informed_subway_routes(t) do
[route_id] ->
"#{route_id} Line trains skip #{informed_stations_string}"
@@ -361,7 +364,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
informed_entities = Alert.informed_entities(alert)
route_id =
- case LocalizedAlert.informed_subway_routes(t) do
+ case LocalizedAlert.consolidated_informed_subway_routes(t) do
["Green" <> _] -> "Green"
[route_id] -> route_id
end
@@ -415,7 +418,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
informed_entities = Alert.informed_entities(alert)
route_id =
- case LocalizedAlert.informed_subway_routes(t) do
+ case LocalizedAlert.consolidated_informed_subway_routes(t) do
["Green" <> _] -> "Green"
[route_id] -> route_id
end
@@ -465,7 +468,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
:inside
) do
%{alert: %{cause: cause, updated_at: updated_at}, now: now} = t
- affected_routes = LocalizedAlert.informed_subway_routes(t)
+ affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t)
routes_at_stop = LocalizedAlert.active_routes_at_stop(t)
unaffected_routes =
@@ -530,7 +533,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
} = t
{delay_description, delay_minutes} = Alert.interpret_severity(severity)
- affected_routes = LocalizedAlert.informed_subway_routes(t)
+ affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t)
duration_text =
case delay_description do
@@ -616,7 +619,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
defp serialize_boundary_alert(%__MODULE__{alert: %Alert{effect: :suspension}} = t, location) do
%{alert: %{cause: cause, header: header}} = t
- affected_routes = LocalizedAlert.informed_subway_routes(t)
+ affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t)
if length(affected_routes) > 1 do
%{
@@ -653,7 +656,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
defp serialize_boundary_alert(%__MODULE__{alert: %Alert{effect: :shuttle}} = t, location) do
%{alert: %{cause: cause, header: header}} = t
- affected_routes = LocalizedAlert.informed_subway_routes(t)
+ affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t)
if length(affected_routes) > 1 do
%{
@@ -715,7 +718,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
)
when severity >= 7 do
%{alert: %{cause: cause, header: header}} = t
- affected_routes = LocalizedAlert.informed_subway_routes(t)
+ affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t)
if length(affected_routes) > 1 do
%{
@@ -768,7 +771,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
) do
%{alert: %{cause: cause, header: header}} = t
informed_entities = Alert.informed_entities(alert)
- affected_routes = LocalizedAlert.informed_subway_routes(t)
+ affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t)
if length(affected_routes) > 1 do
%{
@@ -809,7 +812,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
defp serialize_outside_alert(%__MODULE__{alert: %Alert{effect: :shuttle} = alert} = t, location) do
%{alert: %{cause: cause, header: header}} = t
informed_entities = Alert.informed_entities(alert)
- affected_routes = LocalizedAlert.informed_subway_routes(t)
+ affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t)
if length(affected_routes) > 1 do
%{
@@ -932,16 +935,31 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do
end
end
- def serialize(%__MODULE__{is_full_screen: true} = t) do
- if takeover_alert?(t) do
- serialize_takeover_alert(t)
- else
- location = LocalizedAlert.location(t)
- serialize_fullscreen_alert(t, location)
- end
+ def serialize(widget, log_fn \\ &Logger.warn/1)
+
+ def serialize(%__MODULE__{is_full_screen: true} = t, log_fn) do
+ main_data =
+ if takeover_alert?(t) do
+ serialize_takeover_alert(t)
+ else
+ location = LocalizedAlert.location(t)
+ serialize_fullscreen_alert(t, location)
+ end
+
+ diagram_data =
+ case DisruptionDiagram.serialize(t) do
+ {:ok, serialized_diagram} ->
+ %{disruption_diagram: serialized_diagram}
+
+ {:error, reason} ->
+ log_fn.("[disruption diagram error] #{reason}")
+ %{}
+ end
+
+ Map.merge(main_data, diagram_data)
end
- def serialize(%__MODULE__{is_terminal_station: is_terminal_station} = t) do
+ def serialize(%__MODULE__{is_terminal_station: is_terminal_station} = t, _log_fn) do
case LocalizedAlert.location(t, is_terminal_station) do
:inside ->
t |> serialize_inside_flex_alert() |> Map.put(:region, :inside)
diff --git a/lib/screens/v2/widget_instance/subway_status.ex b/lib/screens/v2/widget_instance/subway_status.ex
index d89bc7f6e..d36fd6fde 100644
--- a/lib/screens/v2/widget_instance/subway_status.ex
+++ b/lib/screens/v2/widget_instance/subway_status.ex
@@ -2,6 +2,7 @@ defmodule Screens.V2.WidgetInstance.SubwayStatus do
@moduledoc false
alias Screens.Alerts.Alert
+ alias Screens.Alerts.InformedEntity
alias Screens.Config.Screen
alias Screens.Config.V2.PreFare
alias Screens.Stops.Stop
@@ -270,29 +271,17 @@ defmodule Screens.V2.WidgetInstance.SubwayStatus do
%{type: :text, color: :green, text: "GL", branches: branches}
end
- defp ie_is_whole_route?(%{route: route_id, direction_id: nil, stop: nil})
- when not is_nil(route_id),
- do: true
-
- defp ie_is_whole_route?(_), do: false
-
- defp ie_is_whole_direction?(%{route: route_id, direction_id: direction_id, stop: nil})
- when not is_nil(route_id) and not is_nil(direction_id),
- do: true
-
- defp ie_is_whole_direction?(_), do: false
-
defp alert_is_whole_route?(informed_entities) do
- Enum.any?(informed_entities, &ie_is_whole_route?/1)
+ Enum.any?(informed_entities, &InformedEntity.whole_route?/1)
end
defp alert_is_whole_direction?(informed_entities) do
- Enum.any?(informed_entities, &ie_is_whole_direction?/1)
+ Enum.any?(informed_entities, &InformedEntity.whole_direction?/1)
end
defp get_direction(informed_entities, route_id) do
[%{direction_id: direction_id} | _] =
- Enum.filter(informed_entities, &ie_is_whole_direction?/1)
+ Enum.filter(informed_entities, &InformedEntity.whole_direction?/1)
direction =
@route_directions
diff --git a/mix.exs b/mix.exs
index 14a68cf5b..a8ed4b4ab 100644
--- a/mix.exs
+++ b/mix.exs
@@ -77,7 +77,8 @@ defmodule Screens.MixProject do
{:sentry, "~> 8.0"},
{:retry, "~> 0.16.0"},
{:stream_data, "~> 0.5", only: :test},
- {:memcachex, "~> 0.5.5"}
+ {:memcachex, "~> 0.5.5"},
+ {:aja, "~> 0.6.2"}
]
end
end
diff --git a/mix.lock b/mix.lock
index 83e73efdf..9a98a7316 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,4 +1,5 @@
%{
+ "aja": {:hex, :aja, "0.6.2", "3eae51bc26dd479ad53b07ec9254bc018ab9b95704db13817df6a1ecf1c817de", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1f0a1aab112dacec73914b4e30a7215cda6cab7b0fb0adf5472dc3bf227d8b34"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
diff --git a/test/screens/v2/disruption_diagram_test.exs b/test/screens/v2/disruption_diagram_test.exs
new file mode 100644
index 000000000..ba24192c8
--- /dev/null
+++ b/test/screens/v2/disruption_diagram_test.exs
@@ -0,0 +1,2062 @@
+defmodule Screens.V2.DisruptionDiagramTest do
+ use ExUnit.Case, async: true
+
+ alias Screens.V2.DisruptionDiagram, as: DD
+ alias Screens.LocationContext
+ alias Screens.Alerts.Alert
+ alias Screens.TestSupport.DisruptionDiagramLocalizedAlert, as: DDAlert
+ alias Screens.TestSupport.SubwayTaggedStopSequences, as: TaggedSeq
+
+ import Screens.TestSupport.ParentStationIdSigil
+
+ describe "serialize/1" do
+ #############
+ # BLUE LINE #
+ #############
+
+ test "serializes a Blue Line shuttle" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :blue, ~P"mvbcl", {~P"wondl", ~P"mvbcl"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {4, 11},
+ line: :blue,
+ current_station_slot_index: 4,
+ slots: [
+ %{type: :terminal, label_id: ~P"bomnl"},
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Aquarium", abbrev: "Aquarium"}, show_symbol: true},
+ %{label: %{full: "Maverick", abbrev: "Maverick"}, show_symbol: true},
+ %{label: %{full: "Airport", abbrev: "Airport"}, show_symbol: true},
+ %{label: %{full: "Wood Island", abbrev: "Wood Island"}, show_symbol: true},
+ %{label: %{full: "Orient Heights", abbrev: "Orient Hts"}, show_symbol: true},
+ %{label: %{full: "Suffolk Downs", abbrev: "Suffolk Dns"}, show_symbol: true},
+ %{label: %{full: "Beachmont", abbrev: "Beachmont"}, show_symbol: true},
+ %{label: %{full: "Revere Beach", abbrev: "Revere Bch"}, show_symbol: true},
+ %{type: :terminal, label_id: ~P"wondl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Blue Line suspension" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :blue, ~P"gover", {~P"state", ~P"bomnl"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {0, 2},
+ line: :blue,
+ current_station_slot_index: 1,
+ slots: [
+ %{type: :terminal, label_id: ~P"bomnl"},
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Aquarium", abbrev: "Aquarium"}, show_symbol: true},
+ %{label: %{full: "Maverick", abbrev: "Maverick"}, show_symbol: true},
+ %{label: %{full: "Airport", abbrev: "Airport"}, show_symbol: true},
+ %{label: %{full: "Wood Island", abbrev: "Wood Island"}, show_symbol: true},
+ %{label: %{full: "Orient Heights", abbrev: "Orient Hts"}, show_symbol: true},
+ %{label: %{full: "Suffolk Downs", abbrev: "Suffolk Dns"}, show_symbol: true},
+ %{label: %{full: "Beachmont", abbrev: "Beachmont"}, show_symbol: true},
+ %{label: %{full: "Revere Beach", abbrev: "Revere Bch"}, show_symbol: true},
+ %{type: :terminal, label_id: ~P"wondl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Blue Line station closure" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :blue, ~P"wondl", ~P[mvbcl aport])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [4, 5],
+ line: :blue,
+ current_station_slot_index: 11,
+ slots: [
+ %{type: :terminal, label_id: ~P"bomnl"},
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Aquarium", abbrev: "Aquarium"}, show_symbol: true},
+ %{label: %{full: "Maverick", abbrev: "Maverick"}, show_symbol: true},
+ %{label: %{full: "Airport", abbrev: "Airport"}, show_symbol: true},
+ %{label: %{full: "Wood Island", abbrev: "Wood Island"}, show_symbol: true},
+ %{label: %{full: "Orient Heights", abbrev: "Orient Hts"}, show_symbol: true},
+ %{label: %{full: "Suffolk Downs", abbrev: "Suffolk Dns"}, show_symbol: true},
+ %{label: %{full: "Beachmont", abbrev: "Beachmont"}, show_symbol: true},
+ %{label: %{full: "Revere Beach", abbrev: "Revere Bch"}, show_symbol: true},
+ %{type: :terminal, label_id: ~P"wondl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Blue Line station closure at Government Center, which is also the home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :blue, ~P"gover", [~P"gover"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [1],
+ line: :blue,
+ current_station_slot_index: 1,
+ slots: [
+ %{type: :terminal, label_id: ~P"bomnl"},
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Aquarium", abbrev: "Aquarium"}, show_symbol: true},
+ %{label: %{full: "Maverick", abbrev: "Maverick"}, show_symbol: true},
+ %{label: %{full: "Airport", abbrev: "Airport"}, show_symbol: true},
+ %{label: %{full: "Wood Island", abbrev: "Wood Island"}, show_symbol: true},
+ %{label: %{full: "Orient Heights", abbrev: "Orient Hts"}, show_symbol: true},
+ %{label: %{full: "Suffolk Downs", abbrev: "Suffolk Dns"}, show_symbol: true},
+ %{label: %{full: "Beachmont", abbrev: "Beachmont"}, show_symbol: true},
+ %{label: %{full: "Revere Beach", abbrev: "Revere Bch"}, show_symbol: true},
+ %{type: :terminal, label_id: ~P"wondl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ ###############
+ # ORANGE LINE #
+ ###############
+
+ test "serializes an Orange Line trunk station closure at Downtown Crossing, which is also the home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :orange, ~P"dwnxg", [~P"dwnxg"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [3],
+ line: :orange,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ #
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ #
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes an Orange Line station closure far from home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :orange, ~P"sbmnl", [~P"welln"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [2],
+ line: :orange,
+ current_station_slot_index: 7,
+ slots: [
+ %{type: :terminal, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true},
+ # Com College, North Sta, Haymarket, State, Downt'n Xng, Chinatown, Tufts Med, Back Bay, Mass Ave, Ruggles, Roxbury Xng
+ %{
+ label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ show_symbol: false
+ },
+ %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true},
+ %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true},
+ #
+ %{type: :terminal, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes an Orange Line suspension spanning most of the line" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :orange, ~P"welln", {~P"astao", ~P"grnst"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {3, 10},
+ line: :orange,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :terminal, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ #
+ #
+ #
+ %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true},
+ %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true},
+ %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true},
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ # Haymarket, State, Downt'n Xng, Chinatown, Tufts Med, Back Bay, Mass Ave, Ruggles, Roxbury Xng
+ %{
+ label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ show_symbol: false
+ },
+ %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true},
+ %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true},
+ %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true},
+ #
+ %{type: :terminal, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a long Orange Line shuttle some distance from the home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :orange, ~P"mlmnl", {~P"ccmnl", ~P"grnst"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {4, 10},
+ line: :orange,
+ current_station_slot_index: 1,
+ slots: [
+ #
+ %{type: :terminal, label_id: ~P"ogmnl"},
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ # Assembly, Sullivan Sq
+ %{label: "…", show_symbol: false},
+ #
+ #
+ %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true},
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ # State, Downt'n Xng, Chinatown, Tufts Med, Back Bay, Mass Ave, Ruggles, Roxbury Xng
+ %{
+ label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ show_symbol: false
+ },
+ #
+ %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true},
+ %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true},
+ %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true},
+ #
+ %{type: :terminal, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a short Orange Line shuttle close to the home stop, at one end of the line" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :orange, ~P"rugg", {~P"jaksn", ~P"forhl"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {4, 7},
+ line: :orange,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :arrow, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true},
+ %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true},
+ %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true},
+ %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true},
+ %{type: :terminal, label_id: ~P"forhl"}
+ #
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a short Orange Line suspension some distance from the home stop, at one end of the line" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :orange, ~P"tumnl", {~P"mlmnl", ~P"astao"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {1, 3},
+ line: :orange,
+ current_station_slot_index: 7,
+ slots: [
+ %{type: :terminal, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true},
+ # Com College, North Sta, Haymarket, State, Downt'n Xng
+ %{
+ label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ show_symbol: false
+ },
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true},
+ %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a long Orange Line suspension near the home stop, at one end of the line" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :orange, ~P"sbmnl", {~P"ccmnl", ~P"rugg"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {1, 6},
+ line: :orange,
+ current_station_slot_index: 9,
+ slots: [
+ %{type: :arrow, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true},
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ # Haymarket, State, Downt'n Xng, Chinatown, Tufts Med
+ %{
+ label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ show_symbol: false
+ },
+ %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true},
+ %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true},
+ %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true},
+ %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true},
+ %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true},
+ #
+ %{type: :terminal, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a long Orange Line shuttle some distance from the home stop, at one end of the line" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :orange, ~P"rcmnl", {~P"mlmnl", ~P"chncl"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 6},
+ line: :orange,
+ current_station_slot_index: 9,
+ slots: [
+ %{type: :terminal, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ # Assembly, Sullivan Sq, Com College, North Sta, Haymarket
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ #
+ #
+ # Tufts Med, Back Bay, Mass Ave
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true},
+ #
+ %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true},
+ %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a short Orange Line station closure near the home stop, around the middle of the line" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :orange, ~P"tumnl", [~P"dwnxg"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [2],
+ line: :orange,
+ current_station_slot_index: 4,
+ slots: [
+ %{type: :arrow, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ #
+ #
+ #
+ %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true},
+ %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a medium Orange Line suspension some distance from the home stop, around the middle of the line" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :orange, ~P"rcmnl", {~P"sull", ~P"dwnxg"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {1, 6},
+ line: :orange,
+ current_station_slot_index: 11,
+ slots: [
+ %{type: :arrow, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true},
+ %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true},
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ # Tufts Med, Back Bay
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true},
+ %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true},
+ %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a long Orange Line shuttle containing the home stop, around the middle of the line" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :orange, ~P"bbsta", {~P"sull", ~P"rugg"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 10},
+ line: :orange,
+ current_station_slot_index: 8,
+ slots: [
+ %{type: :arrow, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true},
+ %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true},
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ # State, Downt'n Xng
+ %{
+ label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ show_symbol: false
+ },
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true},
+ #
+ %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true},
+ %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true},
+ #
+ %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a long Orange Line station closure some distance from the home stop, near the middle of the line" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :orange, ~P"jaksn", [~P"astao", ~P"tumnl"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [2, 5],
+ line: :orange,
+ current_station_slot_index: 9,
+ slots: [
+ %{type: :arrow, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true},
+ # Com College, North Sta, Haymarket, State, Downt'n Xng, Chinatown
+ %{
+ label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ show_symbol: false
+ },
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true},
+ %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true},
+ #
+ #
+ # Mass Ave, Ruggles
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true},
+ %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ # Red - trunk - L terminal - closure omission
+ # Red - trunk - L terminal - gap and closure omission
+ # Red - trunk - L arrow - gap omission
+ # Red - trunk - L arrow - closure omission
+ # Red - trunk - L arrow - gap and closure omission
+ # ...
+ # Red - trunk -arrows - no omissions (good opportunity to test padding small diagram away from JFK)
+
+ ##################
+ # RED LINE TRUNK #
+ ##################
+
+ test "serializes a Red Line trunk station closure at Downtown Crossing, which is also the home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :red, ~P"dwnxg", [~P"dwnxg"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [3],
+ line: :red,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: ~P"alfcl"},
+ #
+ %{label: %{full: "Charles/MGH", abbrev: "Charles/MGH"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ #
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ #
+ %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ # Red - trunk - L terminal - no omission
+ test "serializes a Red Line trunk shuttle near the home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :red, ~P"portr", {~P"knncl", ~P"jfk"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {5, 12},
+ line: :red,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :terminal, label_id: ~P"alfcl"},
+ #
+ %{label: %{full: "Davis", abbrev: "Davis"}, show_symbol: true},
+ %{label: %{full: "Porter", abbrev: "Porter"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Harvard", abbrev: "Harvard"}, show_symbol: true},
+ %{label: %{full: "Central", abbrev: "Central"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Kendall/MIT", abbrev: "Kendall/MIT"}, show_symbol: true},
+ %{label: %{full: "Charles/MGH", abbrev: "Charles/MGH"}, show_symbol: true},
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true},
+ %{label: %{full: "Broadway", abbrev: "Broadway"}, show_symbol: true},
+ %{label: %{full: "Andrew", abbrev: "Andrew"}, show_symbol: true},
+ %{label: %{full: "JFK/UMass", abbrev: "JFK/UMass"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ # Red - trunk - L terminal - gap omission
+ test "serializes a Red Line trunk station closure some distance from the home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :red, ~P"jfk", ~P[davis portr])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [1, 2],
+ line: :red,
+ current_station_slot_index: 10,
+ slots: [
+ #
+ %{type: :terminal, label_id: ~P"alfcl"},
+ %{label: %{full: "Davis", abbrev: "Davis"}, show_symbol: true},
+ %{label: %{full: "Porter", abbrev: "Porter"}, show_symbol: true},
+ %{label: %{full: "Harvard", abbrev: "Harvard"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Central", abbrev: "Central"}, show_symbol: true},
+ %{label: %{full: "Kendall/MIT", abbrev: "Kendall/MIT"}, show_symbol: true},
+ # Charles/MGH, Park St, Downt'n Xng
+ %{
+ label: %{abbrev: "…via Downt'n Xng", full: "…via Downtown Crossing"},
+ show_symbol: false
+ },
+ %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true},
+ %{label: %{full: "Broadway", abbrev: "Broadway"}, show_symbol: true},
+ %{label: %{full: "Andrew", abbrev: "Andrew"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "JFK/UMass", abbrev: "JFK/UMass"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ # Red - trunk - L terminal - no omission, with padding plan A
+ test "serializes a short Red Line station closure next to the home stop, near Alewife" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :red, ~P"portr", [~P"davis"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [1],
+ line: :red,
+ current_station_slot_index: 2,
+ slots: [
+ #
+ %{type: :terminal, label_id: ~P"alfcl"},
+ %{label: %{full: "Davis", abbrev: "Davis"}, show_symbol: true},
+ #
+ %{label: %{full: "Porter", abbrev: "Porter"}, show_symbol: true},
+ #
+ %{label: %{full: "Harvard", abbrev: "Harvard"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Central", abbrev: "Central"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ # Red - trunk - L terminal - no omission, with padding plan B
+ test "serializes a short Red Line station closure near the home stop, which is Alewife" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :red, ~P"alfcl", [~P"davis"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [1],
+ line: :red,
+ current_station_slot_index: 0,
+ slots: [
+ #
+ #
+ %{type: :terminal, label_id: ~P"alfcl"},
+ #
+ %{label: %{full: "Davis", abbrev: "Davis"}, show_symbol: true},
+ %{label: %{full: "Porter", abbrev: "Porter"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Harvard", abbrev: "Harvard"}, show_symbol: true},
+ %{label: %{full: "Central", abbrev: "Central"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ # Red - trunk - L terminal - no omission, with padding plan A and B
+ test "serializes a short Red Line suspension including the home stop, which is Porter" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :red, ~P"portr", {~P"portr", ~P"harsq"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {2, 3},
+ line: :red,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :terminal, label_id: ~P"alfcl"},
+ #
+ %{label: %{full: "Davis", abbrev: "Davis"}, show_symbol: true},
+ #
+ #
+ #
+ %{label: %{full: "Porter", abbrev: "Porter"}, show_symbol: true},
+ %{label: %{full: "Harvard", abbrev: "Harvard"}, show_symbol: true},
+ #
+ #
+ #
+ %{label: %{full: "Central", abbrev: "Central"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ # Red - trunk - L arrow - no omission
+ test "serializes a Red Line trunk shuttle around the middle of the trunk" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :red, ~P"dwnxg", {~P"chmnl", ~P"sstat"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 4},
+ line: :red,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: ~P"alfcl"},
+ %{label: %{full: "Charles/MGH", abbrev: "Charles/MGH"}, show_symbol: true},
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true},
+ %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Red Line trunk shuttle with home stop at JFK" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :red, ~P"jfk", {~P"chmnl", ~P"sstat"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 4},
+ line: :red,
+ current_station_slot_index: 7,
+ slots: [
+ %{type: :arrow, label_id: ~P"alfcl"},
+ #
+ %{label: %{full: "Charles/MGH", abbrev: "Charles/MGH"}, show_symbol: true},
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Broadway", abbrev: "Broadway"}, show_symbol: true},
+ %{label: %{full: "Andrew", abbrev: "Andrew"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "JFK/UMass", abbrev: "JFK/UMass"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Red Line shuttle that crosses from trunk to Ashmont branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :red, ~P"smmnl", {~P"jfk", ~P"fldcr"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 3},
+ line: :red,
+ current_station_slot_index: 4,
+ slots: [
+ %{type: :arrow, label_id: ~P"alfcl"},
+ #
+ %{label: %{abbrev: "JFK/UMass", full: "JFK/UMass"}, show_symbol: true},
+ %{label: %{full: "Savin Hill", abbrev: "Savin Hill"}, show_symbol: true},
+ %{label: %{full: "Fields Corner", abbrev: "Fields Cnr"}, show_symbol: true},
+ #
+ #
+ #
+ %{label: %{full: "Shawmut", abbrev: "Shawmut"}, show_symbol: true},
+ %{type: :terminal, label_id: ~P"asmnl"}
+ #
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Red Line suspension that crosses from trunk to Braintree branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :red, ~P"dwnxg", {~P"jfk", ~P"brntn"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {6, 11},
+ line: :red,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :arrow, label_id: ~P"alfcl"},
+ #
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true},
+ %{label: %{full: "Broadway", abbrev: "Broadway"}, show_symbol: true},
+ %{label: %{full: "Andrew", abbrev: "Andrew"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "JFK/UMass", abbrev: "JFK/UMass"}, show_symbol: true},
+ %{label: %{full: "North Quincy", abbrev: "N Quincy"}, show_symbol: true},
+ %{label: %{full: "Wollaston", abbrev: "Wollaston"}, show_symbol: true},
+ %{label: %{full: "Quincy Center", abbrev: "Quincy Ctr"}, show_symbol: true},
+ %{label: %{full: "Quincy Adams", abbrev: "Quincy Adms"}, show_symbol: true},
+ %{type: :terminal, label_id: "place-brntn"}
+ #
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ ####################
+ # RED LINE ASHMONT #
+ ####################
+
+ # Red - Ashmont - trunk alert with home stop on branch
+ test "serializes a Red Line trunk suspension with home stop on the Ashmont branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :red, ~P"shmnl", {~P"chmnl", ~P"sstat"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 4},
+ line: :red,
+ current_station_slot_index: 8,
+ slots: [
+ %{type: :arrow, label_id: "place-alfcl"},
+ #
+ %{label: %{abbrev: "Charles/MGH", full: "Charles/MGH"}, show_symbol: true},
+ %{label: %{abbrev: "Park St", full: "Park Street"}, show_symbol: true},
+ %{label: %{abbrev: "Downt'n Xng", full: "Downtown Crossing"}, show_symbol: true},
+ %{label: %{abbrev: "South Sta", full: "South Station"}, show_symbol: true},
+ #
+ #
+ %{label: %{abbrev: "Broadway", full: "Broadway"}, show_symbol: true},
+ %{label: %{abbrev: "Andrew", full: "Andrew"}, show_symbol: true},
+ %{label: %{abbrev: "JFK/UMass", full: "JFK/UMass"}, show_symbol: true},
+ #
+ #
+ %{label: %{abbrev: "Savin Hill", full: "Savin Hill"}, show_symbol: true},
+ %{label: %{abbrev: "Fields Cnr", full: "Fields Corner"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: "place-asmnl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ # Red - Ashmont - branch alert with home stop on trunk
+ test "serializes a Red Line Ashmont branch station closure with home stop on the trunk" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :red, ~P"portr", [~P"shmnl"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [7],
+ line: :red,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :terminal, label_id: "place-alfcl"},
+ #
+ %{label: %{abbrev: "Davis", full: "Davis"}, show_symbol: true},
+ %{label: %{abbrev: "Porter", full: "Porter"}, show_symbol: true},
+ #
+ #
+ %{label: %{abbrev: "Harvard", full: "Harvard"}, show_symbol: true},
+ %{
+ label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ show_symbol: false
+ },
+ %{label: %{abbrev: "Andrew", full: "Andrew"}, show_symbol: true},
+ #
+ #
+ %{label: %{abbrev: "JFK/UMass", full: "JFK/UMass"}, show_symbol: true},
+ %{label: %{abbrev: "Savin Hill", full: "Savin Hill"}, show_symbol: true},
+ %{label: %{abbrev: "Fields Cnr", full: "Fields Corner"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: "place-asmnl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Red Line Ashmont branch shuttle with home stop on the Ashmont branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :red, ~P"fldcr", {~P"shmnl", ~P"asmnl"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {2, 5},
+ line: :red,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: ~P"alfcl"},
+ #
+ %{label: %{abbrev: "JFK/UMass", full: "JFK/UMass"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Savin Hill", abbrev: "Savin Hill"}, show_symbol: true},
+ %{label: %{full: "Fields Corner", abbrev: "Fields Cnr"}, show_symbol: true},
+ %{label: %{full: "Shawmut", abbrev: "Shawmut"}, show_symbol: true},
+ #
+ %{type: :terminal, label_id: ~P"asmnl"}
+ #
+ #
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ ######################
+ # RED LINE BRAINTREE #
+ ######################
+
+ # Red - Braintree - trunk alert with home stop on branch
+ test "serializes a Red Line trunk suspension with home stop on the Braintree branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :red, ~P"wlsta", {~P"chmnl", ~P"sstat"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 4},
+ line: :red,
+ current_station_slot_index: 9,
+ slots: [
+ %{type: :arrow, label_id: "place-alfcl"},
+ #
+ %{label: %{abbrev: "Charles/MGH", full: "Charles/MGH"}, show_symbol: true},
+ %{label: %{abbrev: "Park St", full: "Park Street"}, show_symbol: true},
+ %{label: %{abbrev: "Downt'n Xng", full: "Downtown Crossing"}, show_symbol: true},
+ %{label: %{abbrev: "South Sta", full: "South Station"}, show_symbol: true},
+ #
+ #
+ %{label: %{abbrev: "Broadway", full: "Broadway"}, show_symbol: true},
+ %{label: %{abbrev: "Andrew", full: "Andrew"}, show_symbol: true},
+ %{label: %{abbrev: "JFK/UMass", full: "JFK/UMass"}, show_symbol: true},
+ %{label: %{abbrev: "N Quincy", full: "North Quincy"}, show_symbol: true},
+ #
+ #
+ %{label: %{abbrev: "Wollaston", full: "Wollaston"}, show_symbol: true},
+ %{label: %{abbrev: "Quincy Ctr", full: "Quincy Center"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: "place-brntn"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ # Red - Braintree - branch alert with home stop on trunk
+ test "serializes a Red Line Braintree branch station closure with home stop on the trunk" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :red, ~P"portr", [~P"qamnl"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [8],
+ line: :red,
+ current_station_slot_index: 2,
+ slots: [
+ %{label_id: "place-alfcl", type: :terminal},
+ %{label: %{abbrev: "Davis", full: "Davis"}, show_symbol: true},
+ %{label: %{abbrev: "Porter", full: "Porter"}, show_symbol: true},
+ %{label: %{abbrev: "Harvard", full: "Harvard"}, show_symbol: true},
+ %{label: %{abbrev: "Central", full: "Central"}, show_symbol: true},
+ %{
+ label: %{abbrev: "…via Downt'n Xng", full: "…via Downtown Crossing"},
+ show_symbol: false
+ },
+ %{label: %{abbrev: "Wollaston", full: "Wollaston"}, show_symbol: true},
+ %{label: %{abbrev: "Quincy Ctr", full: "Quincy Center"}, show_symbol: true},
+ %{label: %{abbrev: "Quincy Adms", full: "Quincy Adams"}, show_symbol: true},
+ %{label_id: "place-brntn", type: :terminal}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ # Red - Braintree - branch alert with home stop on branch
+ test "serializes a Red Line Braintree branch shuttle with home stop on the Braintree branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :red, ~P"nqncy", {~P"nqncy", ~P"brntn"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {2, 6},
+ line: :red,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :arrow, label_id: ~P"alfcl"},
+ #
+ %{label: %{full: "JFK/UMass", abbrev: "JFK/UMass"}, show_symbol: true},
+ #
+ %{label: %{full: "North Quincy", abbrev: "N Quincy"}, show_symbol: true},
+ #
+ %{label: %{full: "Wollaston", abbrev: "Wollaston"}, show_symbol: true},
+ %{label: %{full: "Quincy Center", abbrev: "Quincy Ctr"}, show_symbol: true},
+ %{label: %{full: "Quincy Adams", abbrev: "Quincy Adms"}, show_symbol: true},
+ %{type: :terminal, label_id: ~P"brntn"}
+ #
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ ####################
+ # GREEN LINE TRUNK #
+ ####################
+
+ test "serializes a Green Line trunk station closure at Government Center, which is also the home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :green, ~P"gover", [~P"gover"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [3],
+ line: :green,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: ~P"coecl" <> "+west"},
+ #
+ %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ #
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ #
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"mdftf" <> "+" <> ~P"unsqu"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Green Line trunk station closure at North Station, which is also the home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :green, ~P"north", [~P"north"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [3],
+ line: :green,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: ~P"coecl" <> "+west"},
+ #
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ #
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ #
+ %{label: %{full: "Science Park/West End", abbrev: "Science Pk"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"mdftf" <> "+" <> ~P"unsqu"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Green Line trunk suspension with home stop on the trunk" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :green, ~P"north", {~P"haecl", ~P"pktrm"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {1, 3},
+ line: :green,
+ current_station_slot_index: 4,
+ slots: [
+ %{type: :arrow, label_id: ~P"coecl" <> "+west"},
+ #
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ #
+ #
+ #
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ %{label: %{full: "Science Park/West End", abbrev: "Science Pk"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"mdftf" <> "+" <> ~P"unsqu"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes the same alert viewed from home stop at Union Square" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :green, ~P"unsqu", {~P"haecl", ~P"pktrm"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {4, 6},
+ line: :green,
+ current_station_slot_index: 0,
+ slots: [
+ #
+ %{type: :terminal, label_id: ~P"unsqu"},
+ #
+ #
+ %{label: %{full: "Lechmere", abbrev: "Lechmere"}, show_symbol: true},
+ %{label: %{full: "Science Park/West End", abbrev: "Science Pk"}, show_symbol: true},
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"river"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes the same alert viewed from home stop on Medford branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :green, ~P"gilmn", {~P"haecl", ~P"pktrm"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {6, 8},
+ line: :green,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :arrow, label_id: ~P"mdftf"},
+ #
+ %{label: %{full: "Magoun Square", abbrev: "Magoun Sq"}, show_symbol: true},
+ %{label: %{full: "Gilman Square", abbrev: "Gilman Sq"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "East Somerville", abbrev: "E Somerville"}, show_symbol: true},
+ # Lechmere, Science Pk
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"hsmnl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes the same alert viewed from home stop on Riverside branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :green, ~P"fenwy", {~P"haecl", ~P"pktrm"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {6, 8},
+ line: :green,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :arrow, label_id: ~P"river"},
+ #
+ %{label: %{full: "Longwood", abbrev: "Longwood"}, show_symbol: true},
+ %{label: %{full: "Fenway", abbrev: "Fenway"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Kenmore", abbrev: "Kenmore"}, show_symbol: true},
+ %{label: %{full: "…via Copley", abbrev: "…via Copley"}, show_symbol: false},
+ %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"unsqu"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes the same alert viewed from home stop on Heath Street branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :green, ~P"symcl", {~P"haecl", ~P"pktrm"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {6, 8},
+ line: :green,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :arrow, label_id: ~P"hsmnl"},
+ #
+ %{label: %{full: "Northeastern University", abbrev: "Northeast'n"}, show_symbol: true},
+ %{label: %{full: "Symphony", abbrev: "Symphony"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Prudential", abbrev: "Prudential"}, show_symbol: true},
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"mdftf"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a trunk alert that does not extend past Government Center when home stop is on Boston College branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :green, ~P"amory", {~P"boyls", ~P"coecl"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {6, 8},
+ line: :green,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :arrow, label_id: ~P"lake"},
+ #
+ %{label: %{full: "Babcock Street", abbrev: "Babcock St"}, show_symbol: true},
+ %{label: %{full: "Amory Street", abbrev: "Amory St"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Boston University Central", abbrev: "BU Central"}, show_symbol: true},
+ %{label: %{full: "…via Kenmore", abbrev: "…via Kenmore"}, show_symbol: false},
+ %{label: %{full: "Hynes Convention Center", abbrev: "Hynes"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Copley", abbrev: "Copley"}, show_symbol: true},
+ %{label: %{full: "Arlington", abbrev: "Arlington"}, show_symbol: true},
+ %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"gover"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a trunk alert that does not extend past Government Center when home stop is on Cleveland Circle branch" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :green, ~P"cool", {~P"boyls", ~P"coecl"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {6, 8},
+ line: :green,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :arrow, label_id: ~P"clmnl"},
+ #
+ %{label: %{full: "Summit Avenue", abbrev: "Summit Ave"}, show_symbol: true},
+ %{label: %{full: "Coolidge Corner", abbrev: "Coolidge Cn"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Saint Paul Street", abbrev: "St. Paul St"}, show_symbol: true},
+ %{label: %{full: "…via Kenmore", abbrev: "…via Kenmore"}, show_symbol: false},
+ %{label: %{full: "Hynes Convention Center", abbrev: "Hynes"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Copley", abbrev: "Copley"}, show_symbol: true},
+ %{label: %{full: "Arlington", abbrev: "Arlington"}, show_symbol: true},
+ %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"gover"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "uses 'Kenmore & West' label for a Green Line trunk alert extending past Copley but not Kenmore" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :green, ~P"coecl", {~P"haecl", ~P"pktrm"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {5, 7},
+ line: :green,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :arrow, label_id: ~P"kencl+west"},
+ #
+ %{label: %{full: "Hynes Convention Center", abbrev: "Hynes"}, show_symbol: true},
+ %{label: %{full: "Copley", abbrev: "Copley"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Arlington", abbrev: "Arlington"}, show_symbol: true},
+ %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"mdftf" <> "+" <> ~P"unsqu"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ #######################
+ # GREEN LINE BRANCHES #
+ #######################
+
+ test "serializes a Medford branch alert" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :green, ~P"gilmn", [~P"mgngl"])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [2],
+ line: :green,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :terminal, label_id: ~P"mdftf"},
+ %{label: %{full: "Ball Square", abbrev: "Ball Sq"}, show_symbol: true},
+ %{label: %{full: "Magoun Square", abbrev: "Magoun Sq"}, show_symbol: true},
+ %{label: %{full: "Gilman Square", abbrev: "Gilman Sq"}, show_symbol: true},
+ %{label: %{full: "East Somerville", abbrev: "E Somerville"}, show_symbol: true},
+ %{type: :arrow, label_id: ~P"hsmnl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Union Square branch alert" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :green, ~P"unsqu", {~P"unsqu", ~P"lech"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {0, 1},
+ line: :green,
+ current_station_slot_index: 0,
+ slots: [
+ %{type: :terminal, label_id: ~P"unsqu"},
+ %{label: %{full: "Lechmere", abbrev: "Lechmere"}, show_symbol: true},
+ %{label: %{full: "Science Park/West End", abbrev: "Science Pk"}, show_symbol: true},
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ %{type: :arrow, label_id: ~P"river"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Boston College branch alert" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :green, ~P"sthld", {~P"babck", ~P"alsgr"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {5, 9},
+ line: :green,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :arrow, label_id: ~P"lake"},
+ #
+ %{label: %{full: "Chiswick Road", abbrev: "Chiswick Rd"}, show_symbol: true},
+ %{label: %{full: "Sutherland Road", abbrev: "Sutherland"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Washington Street", abbrev: "Washington"}, show_symbol: true},
+ %{label: %{full: "Warren Street", abbrev: "Warren St"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Allston Street", abbrev: "Allston St"}, show_symbol: true},
+ %{label: %{full: "Griggs Street", abbrev: "Griggs St"}, show_symbol: true},
+ %{label: %{full: "Harvard Avenue", abbrev: "Harvard Ave"}, show_symbol: true},
+ %{label: %{full: "Packards Corner", abbrev: "Packards Cn"}, show_symbol: true},
+ %{label: %{full: "Babcock Street", abbrev: "Babcock St"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"gover"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Cleveland Circle branch alert" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :green, ~P"cool", {~P"sumav", ~P"bndhl"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 2},
+ line: :green,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: ~P"clmnl"},
+ #
+ %{label: %{full: "Brandon Hall", abbrev: "Brandon Hll"}, show_symbol: true},
+ %{label: %{full: "Summit Avenue", abbrev: "Summit Ave"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Coolidge Corner", abbrev: "Coolidge Cn"}, show_symbol: true},
+ %{label: %{full: "Saint Paul Street", abbrev: "St. Paul St"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"gover"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Riverside branch alert" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :green, ~P"rsmnl", {~P"chhil", ~P"newto"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 2},
+ line: :green,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: ~P"river"},
+ #
+ %{label: %{full: "Newton Centre", abbrev: "Newton Ctr"}, show_symbol: true},
+ %{label: %{full: "Chestnut Hill", abbrev: "Chestnut Hl"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Reservoir", abbrev: "Reservoir"}, show_symbol: true},
+ %{label: %{full: "Beaconsfield", abbrev: "B'consfield"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"unsqu"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Heath Street branch alert" do
+ localized_alert =
+ DDAlert.make_localized_alert(:suspension, :green, ~P"symcl", {~P"brmnl", ~P"hsmnl"})
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {0, 5},
+ line: :green,
+ current_station_slot_index: 9,
+ slots: [
+ #
+ %{type: :terminal, label_id: ~P"hsmnl"},
+ %{label: %{full: "Back of the Hill", abbrev: "Back o'Hill"}, show_symbol: true},
+ %{label: %{full: "Riverway", abbrev: "Riverway"}, show_symbol: true},
+ %{label: %{full: "Mission Park", abbrev: "Mission Pk"}, show_symbol: true},
+ %{label: %{full: "Fenwood Road", abbrev: "Fenwood Rd"}, show_symbol: true},
+ %{label: %{full: "Brigham Circle", abbrev: "Brigham Cir"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Longwood Medical Area", abbrev: "Lngwd Med"}, show_symbol: true},
+ %{label: %{full: "Museum of Fine Arts", abbrev: "MFA"}, show_symbol: true},
+ %{label: %{full: "Northeastern University", abbrev: "Northeast'n"}, show_symbol: true},
+ #
+ #
+ %{label: %{full: "Symphony", abbrev: "Symphony"}, show_symbol: true},
+ %{label: %{full: "Prudential", abbrev: "Prudential"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: ~P"mdftf"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "serializes a Cleveland Circle branch alert with home stop at Government Center" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :green, ~P"gover", {~P"smary", ~P"cool"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 5},
+ line: :green,
+ current_station_slot_index: 11,
+ slots: [
+ %{type: :arrow, label_id: ~P"clmnl"},
+ %{label: %{full: "Coolidge Corner", abbrev: "Coolidge Cn"}, show_symbol: true},
+ %{label: %{full: "Saint Paul Street", abbrev: "St. Paul St"}, show_symbol: true},
+ %{label: %{full: "Kent Street", abbrev: "Kent St"}, show_symbol: true},
+ %{label: %{full: "Hawes Street", abbrev: "Hawes St"}, show_symbol: true},
+ %{label: %{full: "Saint Mary's Street", abbrev: "St. Mary's"}, show_symbol: true},
+ %{label: %{full: "Kenmore", abbrev: "Kenmore"}, show_symbol: true},
+ %{label: %{full: "Hynes Convention Center", abbrev: "Hynes"}, show_symbol: true},
+ %{
+ label: %{full: "…via Copley", abbrev: "…via Copley"},
+ show_symbol: false
+ },
+ %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true},
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ %{type: :terminal, label_id: ~P"gover"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ ##############
+ # VALIDATION #
+ ##############
+
+ test "rejects irrelevant alert effects" do
+ delay_scenario = %{
+ alert: %Alert{effect: :delay, informed_entities: [%{route: "Orange", stop: ~P"rugg"}]},
+ location_context: %LocationContext{
+ home_stop: ~P"bbsta",
+ tagged_stop_sequences: TaggedSeq.orange()
+ }
+ }
+
+ assert {:error, "invalid effect: delay"} = DD.serialize(delay_scenario)
+ end
+
+ test "rejects whole-route alerts" do
+ whole_route_scenario = %{
+ alert: %Alert{
+ effect: :suspension,
+ informed_entities: [%{route: "Orange", stop: nil, direction_id: nil}]
+ },
+ location_context: %LocationContext{
+ home_stop: ~P"bbsta",
+ tagged_stop_sequences: TaggedSeq.orange()
+ }
+ }
+
+ assert {:error, "alert informs an entire route"} = DD.serialize(whole_route_scenario)
+ end
+
+ test "rejects shuttle and suspension alerts that inform only one stop" do
+ one_stop_shuttle =
+ DDAlert.make_localized_alert(:shuttle, :blue, ~P"gover", {~P"mvbcl", ~P"mvbcl"})
+
+ assert {:error, "shuttle alert does not inform at least 2 stops"} =
+ DD.serialize(one_stop_shuttle)
+
+ one_stop_suspension =
+ DDAlert.make_localized_alert(:suspension, :green, ~P"north", {~P"kencl", ~P"kencl"})
+
+ assert {:error, "suspension alert does not inform at least 2 stops"} =
+ DD.serialize(one_stop_suspension)
+ end
+
+ test "rejects alerts that inform multiple lines" do
+ multi_line_scenario = %{
+ alert: %Alert{
+ effect: :station_closure,
+ informed_entities: [
+ %{route: "Blue", stop: ~P"gover"}
+ | Enum.map(~w[B C D E], &%{route: "Green-#{&1}", stop: ~P"gover"})
+ ]
+ },
+ location_context: %LocationContext{
+ home_stop: ~P"gover",
+ tagged_stop_sequences: Map.merge(TaggedSeq.blue(), TaggedSeq.green())
+ }
+ }
+
+ assert {:error, "alert does not inform exactly one subway line"} =
+ DD.serialize(multi_line_scenario)
+ end
+
+ test "rejects alerts whose informed stops do not all lay along one stop sequence" do
+ branched_scenario = %{
+ alert: %Alert{
+ effect: :station_closure,
+ informed_entities: [
+ %{route: "Green-D", stop: ~P"unsqu"},
+ %{route: "Green-E", stop: ~P"mdftf"}
+ ]
+ },
+ location_context: %LocationContext{
+ home_stop: ~P"gover",
+ tagged_stop_sequences: TaggedSeq.green()
+ }
+ }
+
+ assert {:error, "no stop sequence contains both the home stop and all informed stops"} =
+ DD.serialize(branched_scenario)
+ end
+
+ test "rejects alerts whose informed stops include a branch that's not directly reachable from the home stop" do
+ unreachable_branch_scenario = %{
+ alert: %Alert{
+ effect: :shuttle,
+ informed_entities: [
+ %{route: "Green-E", stop: ~P"coecl"},
+ %{route: "Green-E", stop: ~P"prmnl"},
+ %{route: "Green-E", stop: ~P"symcl"}
+ ]
+ },
+ location_context: %LocationContext{
+ home_stop: ~P"unsqu",
+ tagged_stop_sequences: TaggedSeq.green([:d])
+ }
+ }
+
+ assert {:error, "no stop sequence contains both the home stop and all informed stops"} =
+ DD.serialize(unreachable_branch_scenario)
+ end
+
+ ##############
+ # EDGE CASES #
+ ##############
+
+ test "does not omit from an alert that spans 9 stops and contains the home stop" do
+ # In this case, the closure has more than 8 slots available to it and doesn't get shrunk.
+ localized_alert =
+ DDAlert.make_localized_alert(
+ :suspension,
+ :orange,
+ ~P"haecl",
+ {~P"ccmnl", ~P"masta"}
+ )
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {1, 9},
+ line: :orange,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: "place-ogmnl"},
+ #
+ %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true},
+ #
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ #
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true},
+ %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true},
+ %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: "place-forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "does not omit from an alert that spans 10 stops and contains the home stop" do
+ # In this case, the closure has more than 8 slots available to it and doesn't get shrunk.
+ localized_alert =
+ DDAlert.make_localized_alert(
+ :suspension,
+ :orange,
+ ~P"haecl",
+ {~P"ccmnl", ~P"rugg"}
+ )
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {1, 10},
+ line: :orange,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: "place-ogmnl"},
+ #
+ %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true},
+ #
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ #
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true},
+ %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true},
+ %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true},
+ %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: "place-forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "omits from an alert that spans more than 10 stops and contains the home stop" do
+ # The largest a closure can possibly be is 10 slots.
+ localized_alert =
+ DDAlert.make_localized_alert(
+ :suspension,
+ :orange,
+ ~P"haecl",
+ {~P"ccmnl", ~P"rcmnl"}
+ )
+
+ expected = %{
+ effect: :suspension,
+ effect_region_slot_index_range: {1, 10},
+ line: :orange,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: "place-ogmnl"},
+ #
+ %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true},
+ #
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ #
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ #
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ # Chinatown, Tufts Med
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true},
+ %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true},
+ %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true},
+ %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true},
+ #
+ %{type: :arrow, label_id: "place-forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "for long shuttles with home stop near the middle, omits stops off-center to avoid omitting the home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:shuttle, :orange, ~P"bbsta", {~P"mlmnl", ~P"grnst"})
+
+ expected = %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {1, 10},
+ line: :orange,
+ current_station_slot_index: 4,
+ slots: [
+ %{type: :terminal, label_id: ~P"ogmnl"},
+ #
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ # Assembly, Sullivan Sq, Com College, North Sta, Haymarket, State, Downt'n Xng, Chinatown, Tufts Med
+ # (Shifted left 3 to avoid omitting home stop at Back Bay)
+ %{
+ label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ show_symbol: false
+ },
+ #
+ %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true},
+ %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true},
+ #
+ %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true},
+ %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true},
+ %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true},
+ %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true},
+ %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true},
+ #
+ %{type: :terminal, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "for long station closures with closures near the middle, omits stops off-center to avoid omitting the home stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :orange, ~P"welln", ~P[mlmnl haecl grnst])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [1, 7, 10],
+ line: :orange,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :terminal, label_id: ~P"ogmnl"},
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true},
+ %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true},
+ %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true},
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ # State, Downt'n Xng, Chinatown, Tufts Med, Back Bay, Mass Ave, Ruggles, Roxbury Xng, Jackson Sq
+ %{
+ label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"},
+ show_symbol: false
+ },
+ %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true},
+ %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true},
+ %{type: :terminal, label_id: ~P"forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "splits omission around an important stop when necessary" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :orange, ~P"welln", ~P[mlmnl dwnxg grnst])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [1, 5, 10],
+ line: :orange,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :terminal, label_id: "place-ogmnl"},
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true},
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true},
+ %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true},
+ %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true},
+ %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true},
+ %{type: :terminal, label_id: "place-forhl"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ test "absolute worst case scenario--split omission + gap omission" do
+ localized_alert =
+ DDAlert.make_localized_alert(:station_closure, :green, ~P"unsqu", ~P[boyls brkhl waban])
+
+ expected = %{
+ effect: :station_closure,
+ closed_station_slot_indices: [2, 4, 7],
+ line: :green,
+ current_station_slot_index: 11,
+ slots: [
+ %{type: :terminal, label_id: "place-river"},
+ %{label: %{full: "Woodland", abbrev: "Woodland"}, show_symbol: true},
+ %{label: %{full: "Waban", abbrev: "Waban"}, show_symbol: true},
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "Brookline Hills", abbrev: "B'kline Hls"}, show_symbol: true},
+ %{
+ label: %{full: "…via Kenmore & Copley", abbrev: "…via Kenmore & Copley"},
+ show_symbol: false
+ },
+ %{label: %{full: "Arlington", abbrev: "Arlington"}, show_symbol: true},
+ %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true},
+ %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true},
+ %{label: "…", show_symbol: false},
+ %{label: %{full: "Lechmere", abbrev: "Lechmere"}, show_symbol: true},
+ %{type: :terminal, label_id: "place-unsqu"}
+ ]
+ }
+
+ assert {:ok, actual} = DD.serialize(localized_alert)
+
+ assert expected == actual
+ end
+
+ ###########
+ # FAILURE #
+ ###########
+
+ test "fails to serialize a station closure that's impossible to fit without omitting an important stop" do
+ localized_alert =
+ DDAlert.make_localized_alert(
+ :station_closure,
+ :orange,
+ ~P"welln",
+ ~P[mlmnl astao ccmnl haecl dwnxg tumnl masta rcmnl sbmnl]
+ )
+
+ expected =
+ {:error, "can't omit 9 from closure region without omitting at least one important stop"}
+
+ assert expected == DD.serialize(localized_alert)
+ end
+ end
+end
diff --git a/test/screens/v2/widget_instance/reconstructed_alert_property_test.exs b/test/screens/v2/widget_instance/reconstructed_alert_property_test.exs
index 5cf9d2155..adc9302f1 100644
--- a/test/screens/v2/widget_instance/reconstructed_alert_property_test.exs
+++ b/test/screens/v2/widget_instance/reconstructed_alert_property_test.exs
@@ -1169,8 +1169,12 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertPropertyTest do
fetch_location_context_fn
)
+ # We can't build disruption diagrams for some of these alert scenarios.
+ # Prevent `ReconstructedAlert.serialize` from filling the console with log noise when this happens.
+ fake_log = fn _message -> nil end
+
Enum.each(alert_widgets, fn widget ->
- assert %{issue: _, location: _} = ReconstructedAlert.serialize(widget)
+ assert %{issue: _, location: _} = ReconstructedAlert.serialize(widget, fake_log)
end)
end
end
diff --git a/test/screens/v2/widget_instance/reconstructed_alert_test.exs b/test/screens/v2/widget_instance/reconstructed_alert_test.exs
index 7a864c698..75e60cb01 100644
--- a/test/screens/v2/widget_instance/reconstructed_alert_test.exs
+++ b/test/screens/v2/widget_instance/reconstructed_alert_test.exs
@@ -311,6 +311,10 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
%{widget: put_effect(widget, :station_closure)}
end
+ # We can't build disruption diagrams for some of these alert scenarios.
+ # Prevent `ReconstructedAlert.serialize` from filling the console with log noise when this happens.
+ defp fake_log(_message), do: nil
+
# Pass this to `setup` to set up "context" data on the alert widget, without setting up the API alert itself.
@transfer_stations_alert_widget_context_setup_group [
:setup_transfer_station,
@@ -498,7 +502,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
endpoints: {"Malden Center", "Malden Center"}
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles shuttle", %{widget: widget} do
@@ -523,7 +527,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
endpoints: {"Malden Center", "Malden Center"}
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles station closure", %{widget: widget} do
@@ -544,10 +548,22 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Seek alternate route",
updated_at: "Friday, 5:00 am",
routes: [%{color: :orange, text: "ORANGE LINE", type: :text}],
- other_closures: ["Malden Center"]
+ other_closures: ["Malden Center"],
+ disruption_diagram: %{
+ effect: :station_closure,
+ closed_station_slot_indices: [1],
+ line: :orange,
+ current_station_slot_index: 1,
+ slots: [
+ %{type: :terminal, label_id: "place-ogmnl"},
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{type: :terminal, label_id: "place-astao"}
+ ]
+ }
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles alert with cause", %{widget: widget} do
@@ -572,7 +588,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
endpoints: {"Malden Center", "Malden Center"}
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles terminal boundary suspension", %{widget: widget} do
@@ -595,10 +611,22 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Seek alternate route",
updated_at: "Friday, 5:00 am",
routes: [%{color: :orange, text: "ORANGE LINE", type: :text}],
- endpoints: {"Oak Grove", "Malden Center"}
+ endpoints: {"Oak Grove", "Malden Center"},
+ disruption_diagram: %{
+ effect: :suspension,
+ effect_region_slot_index_range: {0, 1},
+ line: :orange,
+ current_station_slot_index: 1,
+ slots: [
+ %{type: :terminal, label_id: "place-ogmnl"},
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{type: :terminal, label_id: "place-astao"}
+ ]
+ }
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles terminal boundary shuttle", %{widget: widget} do
@@ -621,10 +649,22 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Use shuttle bus",
updated_at: "Friday, 5:00 am",
routes: [%{color: :orange, text: "ORANGE LINE", type: :text}],
- endpoints: {"Oak Grove", "Malden Center"}
+ endpoints: {"Oak Grove", "Malden Center"},
+ disruption_diagram: %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {0, 1},
+ line: :orange,
+ current_station_slot_index: 1,
+ slots: [
+ %{type: :terminal, label_id: "place-ogmnl"},
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{type: :terminal, label_id: "place-astao"}
+ ]
+ }
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
end
@@ -652,10 +692,22 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
updated_at: "Friday, 5:00 am",
region: :boundary,
endpoints: {"Oak Grove", "Malden Center"},
- is_transfer_station: false
+ is_transfer_station: false,
+ disruption_diagram: %{
+ effect: :suspension,
+ effect_region_slot_index_range: {0, 1},
+ line: :orange,
+ current_station_slot_index: 1,
+ slots: [
+ %{type: :terminal, label_id: "place-ogmnl"},
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{type: :terminal, label_id: "place-astao"}
+ ]
+ }
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles boundary shuttle", %{widget: widget} do
@@ -679,10 +731,22 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
updated_at: "Friday, 5:00 am",
region: :boundary,
endpoints: {"Oak Grove", "Malden Center"},
- is_transfer_station: false
+ is_transfer_station: false,
+ disruption_diagram: %{
+ effect: :shuttle,
+ effect_region_slot_index_range: {0, 1},
+ line: :orange,
+ current_station_slot_index: 1,
+ slots: [
+ %{type: :terminal, label_id: "place-ogmnl"},
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{type: :terminal, label_id: "place-astao"}
+ ]
+ }
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles moderate delay", %{widget: widget} do
@@ -707,7 +771,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
region: :here
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles severe delay", %{widget: widget} do
@@ -732,7 +796,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
region: :here
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles directional delay", %{widget: widget} do
@@ -757,7 +821,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
region: :here
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles alert with cause", %{widget: widget} do
@@ -782,7 +846,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
region: :here
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles downstream delay", %{widget: widget} do
@@ -807,7 +871,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
region: :outside
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles downstream shuttle", %{widget: widget} do
@@ -833,7 +897,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
is_transfer_station: false
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles downstream suspension", %{widget: widget} do
@@ -857,10 +921,22 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
updated_at: "Friday, 5:00 am",
region: :outside,
endpoints: {"Wellington", "Assembly"},
- is_transfer_station: false
+ is_transfer_station: false,
+ disruption_diagram: %{
+ effect: :suspension,
+ effect_region_slot_index_range: {2, 3},
+ line: :orange,
+ current_station_slot_index: 1,
+ slots: [
+ %{type: :terminal, label_id: "place-ogmnl"},
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{type: :terminal, label_id: "place-astao"}
+ ]
+ }
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
end
@@ -884,10 +960,24 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
routes: [%{route_id: "Orange", svg_name: "ol"}],
effect: :station_closure,
updated_at: "Friday, 5:00 am",
- region: :here
+ region: :here,
+ disruption_diagram: %{
+ effect: :station_closure,
+ closed_station_slot_indices: [3],
+ line: :orange,
+ current_station_slot_index: 3,
+ slots: [
+ %{type: :arrow, label_id: "place-ogmnl"},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ %{type: :arrow, label_id: "place-forhl"}
+ ]
+ }
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles :inside suspension on 1 line", %{widget: widget} do
@@ -913,7 +1003,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
is_transfer_station: true
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles :inside shuttle on 1 line", %{widget: widget} do
@@ -939,7 +1029,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
is_transfer_station: true
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles multi line delay", %{widget: widget} do
@@ -964,7 +1054,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
region: :here
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
end
@@ -993,7 +1083,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: ""
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles severe delay", %{widget: widget} do
@@ -1017,7 +1107,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: ""
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
end
@@ -1045,7 +1135,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Seek alternate route"
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles shuttle", %{widget: widget} do
@@ -1069,7 +1159,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Use shuttle bus"
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles moderate delay", %{widget: widget} do
@@ -1095,7 +1185,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: ""
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles severe delay", %{widget: widget} do
@@ -1120,7 +1210,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: ""
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles alert with cause", %{widget: widget} do
@@ -1145,7 +1235,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: ""
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
end
@@ -1172,7 +1262,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Seek alternate route"
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles downstream suspension range", %{widget: widget} do
@@ -1196,7 +1286,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Seek alternate route"
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles downstream suspension range, one direction only", %{widget: widget} do
@@ -1220,7 +1310,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Seek alternate route"
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles shuttle at one stop", %{widget: widget} do
@@ -1243,7 +1333,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Use shuttle bus"
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles station closure", %{widget: widget} do
@@ -1269,7 +1359,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Seek alternate route"
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles delay", %{widget: widget} do
@@ -1293,7 +1383,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: ""
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
test "handles alert with cause", %{widget: widget} do
@@ -1319,7 +1409,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Seek alternate route"
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
end
@@ -1379,7 +1469,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Use shuttle bus"
}
- assert expected == ReconstructedAlert.serialize(widget)
+ assert expected == ReconstructedAlert.serialize(widget, &fake_log/1)
end
end
@@ -1692,10 +1782,32 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
updated_at: "Friday, 5:14 am",
region: :outside,
endpoints: {"North Station", "Back Bay"},
- is_transfer_station: false
+ is_transfer_station: false,
+ disruption_diagram: %{
+ effect: :suspension,
+ effect_region_slot_index_range: {6, 12},
+ line: :orange,
+ current_station_slot_index: 2,
+ slots: [
+ %{type: :terminal, label_id: "place-ogmnl"},
+ %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true},
+ %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true},
+ %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true},
+ %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true},
+ %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true},
+ %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true},
+ %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true},
+ %{label: %{full: "State", abbrev: "State"}, show_symbol: true},
+ %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true},
+ %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true},
+ %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true},
+ %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true},
+ %{type: :arrow, label_id: "place-forhl"}
+ ]
+ }
}
- assert expected == ReconstructedAlert.serialize(alert_widget)
+ assert expected == ReconstructedAlert.serialize(alert_widget, &fake_log/1)
# Flexzone test
expected = %{
@@ -1709,7 +1821,11 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Seek alternate route"
}
- assert expected == ReconstructedAlert.serialize(%{alert_widget | is_full_screen: false})
+ assert expected ==
+ ReconstructedAlert.serialize(
+ %{alert_widget | is_full_screen: false},
+ &fake_log/1
+ )
end
test "handles GL boundary shuttle at Govt Center" do
@@ -2087,7 +2203,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
is_transfer_station: false
}
- assert expected == ReconstructedAlert.serialize(alert_widget)
+ assert expected == ReconstructedAlert.serialize(alert_widget, &fake_log/1)
# Flexzone test
expected = %{
@@ -2101,7 +2217,11 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do
remedy: "Use shuttle bus"
}
- assert expected == ReconstructedAlert.serialize(%{alert_widget | is_full_screen: false})
+ assert expected ==
+ ReconstructedAlert.serialize(
+ %{alert_widget | is_full_screen: false},
+ &fake_log/1
+ )
end
end
end
diff --git a/test/support/disruption_diagram_localized_alert.ex b/test/support/disruption_diagram_localized_alert.ex
new file mode 100644
index 000000000..b57237f2d
--- /dev/null
+++ b/test/support/disruption_diagram_localized_alert.ex
@@ -0,0 +1,183 @@
+defmodule Screens.TestSupport.DisruptionDiagramLocalizedAlert do
+ @moduledoc """
+ Provides a function that generates localized alerts intended for
+ use with disruption diagrams.
+
+ Only the struct fields required by disruption diagrams are populated,
+ so this might not work for testing other code related to localized alerts.
+ """
+
+ alias Screens.Alerts.Alert
+ alias Screens.LocationContext
+ alias Screens.Stops.Stop
+
+ @doc """
+ Creates a localized alert with the given effect, located at the given home station.
+
+ When creating a station closure alert, `informed_stops` should be a list of stop IDs.
+
+ When creating a shuttle or suspension, `informed_stops` should be a tuple of `{first_stop_id, last_stop_id}`.
+ Keep in mind that stop order will be based on sequences for direction_id=0.
+ For example, a shuttle from DTX to Back Bay must be entered as
+ `{"place-dwnxg", "place-bbsta"}`, not `{"place-bbsta", "place-dwnxg"}`.
+
+ Options:
+ - :informed_routes
+ - If `:per_stop`, the informed route(s) for each stop will be all subway routes that serve it.
+ When a GL trunk stop is disrupted, it will always get informed entities for all routes that serve it,
+ even if the alert later goes down one particular branch.
+ - If `:overall`, the informed route(s) will be whichever fully contain all informed stops.
+ For alerts that inform any GL branch stops, this means the only informed route will be that branch.
+ This is the default AlertsUI behavior.
+ - Defaults to `:overall`.
+ """
+ def make_localized_alert(effect, line, home_station_id, informed_stops, opts \\ [])
+
+ def make_localized_alert(:station_closure, line, home_station_id, stop_ids, opts)
+ when is_list(stop_ids) do
+ alert = %Alert{
+ effect: :station_closure,
+ informed_entities:
+ ies(line, stop_ids, Keyword.get(opts, :informed_routes, :overall), home_station_id)
+ }
+
+ %{alert: alert, location_context: make_location_context(home_station_id)}
+ end
+
+ def make_localized_alert(continuous, line, home_station_id, {_first, _last} = stop_range, opts)
+ when continuous in [:shuttle, :suspension] do
+ alert = %Alert{
+ effect: continuous,
+ informed_entities:
+ ies(
+ line,
+ stop_range_to_list(stop_range),
+ Keyword.get(opts, :informed_routes, :overall),
+ home_station_id
+ )
+ }
+
+ %{alert: alert, location_context: make_location_context(home_station_id)}
+ end
+
+ defp make_location_context(home_station_id) do
+ %LocationContext{
+ home_stop: home_station_id,
+ tagged_stop_sequences: tagged_stop_sequences_through_station(home_station_id)
+ }
+ end
+
+ defp ies(:green, stop_ids, :per_stop, _home_stop) do
+ for stop_id <- stop_ids,
+ "Green" <> _ = route_id <- subway_routes_at_station(stop_id),
+ do: %{route: route_id, stop: stop_id}
+ end
+
+ defp ies(:green, stop_ids, :overall, home_stop) do
+ route_ids =
+ [home_stop | stop_ids]
+ |> MapSet.new()
+ |> routes_containing_all()
+ |> Enum.filter(&match?("Green" <> _, &1))
+
+ result =
+ for stop_id <- stop_ids,
+ route_id <- route_ids,
+ do: %{route: route_id, stop: stop_id}
+
+ if result == [] do
+ raise "No stop sequence contains all informed stops + home stop"
+ else
+ result
+ end
+ end
+
+ defp ies(line, stop_ids, _, _) when line in [:blue, :orange, :red] do
+ route_id =
+ line
+ |> Atom.to_string()
+ |> String.capitalize()
+
+ for stop_id <- stop_ids, do: %{route: route_id, stop: stop_id}
+ end
+
+ defp stop_range_to_list({first_station_id, last_station_id}) do
+ endpoints_set = MapSet.new([first_station_id, last_station_id])
+
+ Stop.get_all_routes_stop_sequence()
+ |> Enum.find_value(fn
+ {_route_id, labeled_sequences} ->
+ Enum.find_value(labeled_sequences, fn labeled_sequence ->
+ stop_sequence = Enum.map(labeled_sequence, &elem(&1, 0))
+ if MapSet.subset?(endpoints_set, MapSet.new(stop_sequence)), do: stop_sequence
+ end)
+ end)
+ |> case do
+ nil ->
+ raise "No stop sequence contains both of the two given stations: {#{first_station_id}, #{last_station_id}}"
+
+ sequence ->
+ index_of_first = Enum.find_index(sequence, &(&1 == first_station_id))
+ index_of_last = Enum.find_index(sequence, &(&1 == last_station_id))
+
+ Enum.slice(sequence, index_of_first..index_of_last//1)
+ end
+ end
+
+ # Returns IDs of the subway/light rail route(s) that serve the given station,
+ # using our hardcoded stop sequences rather than API calls.
+ defp subway_routes_at_station(parent_station_id) do
+ Stop.get_all_routes_stop_sequence()
+ |> Enum.filter(fn
+ # Green isn't a real route ID, ignore it.
+ {"Green", _} ->
+ false
+
+ {_route_id, labeled_sequences} ->
+ stop_sequences =
+ Enum.map(labeled_sequences, fn labeled_sequence ->
+ Enum.map(labeled_sequence, &elem(&1, 0))
+ end)
+
+ Enum.any?(stop_sequences, &(parent_station_id in &1))
+ end)
+ |> Enum.map(fn {route_id, _stop_sequences} -> route_id end)
+ end
+
+ # Returns a %{route => stop_sequences} map for all sequences that that contain the given subway/light rail station.
+ defp tagged_stop_sequences_through_station(parent_station_id) do
+ Stop.get_all_routes_stop_sequence()
+ |> Enum.flat_map(fn
+ # Green isn't a real route ID, ignore it.
+ {"Green", _} ->
+ []
+
+ {route_id, labeled_sequences} ->
+ matching_stop_sequences =
+ Enum.flat_map(labeled_sequences, fn labeled_sequence ->
+ stop_sequence = Enum.map(labeled_sequence, &elem(&1, 0))
+ if parent_station_id in stop_sequence, do: [stop_sequence], else: []
+ end)
+
+ if matching_stop_sequences != [], do: [{route_id, matching_stop_sequences}], else: []
+ end)
+ |> Map.new()
+ end
+
+ # Returns IDs of the route(s) whose stop sequence(s) contain all of the given stops.
+ defp routes_containing_all(parent_station_ids) do
+ Stop.get_all_routes_stop_sequence()
+ |> Enum.filter(fn
+ # Green isn't a real route ID, ignore it.
+ {"Green", _} ->
+ false
+
+ {_route_id, labeled_sequences} ->
+ Enum.any?(labeled_sequences, fn labeled_sequence ->
+ stops = MapSet.new(labeled_sequence, &elem(&1, 0))
+ MapSet.subset?(parent_station_ids, stops)
+ end)
+ end)
+ |> Enum.map(fn {route_id, _} -> route_id end)
+ end
+end
diff --git a/test/support/parent_station_id_sigil.ex b/test/support/parent_station_id_sigil.ex
new file mode 100644
index 000000000..8a98de5f3
--- /dev/null
+++ b/test/support/parent_station_id_sigil.ex
@@ -0,0 +1,29 @@
+defmodule Screens.TestSupport.ParentStationIdSigil do
+ @doc ~S"""
+ Makes a single `"place-#{term}"` string, or a list of them if term contains 2+ words.
+ Can be used in patterns and guards.
+
+ ```
+ iex> import Screens.TestSupport.ParentStationIdSigil
+
+ iex> ~P"haecl"
+ "place-haecl"
+
+ iex> ~P[alfcl davis portr]
+ ["place-alfcl", "place-davis", "place-portr"]
+
+ # The use of "" vs [] doesn't make a difference, they just help to indicate the type.
+ iex> ~P[haecl]
+ "place-haecl"
+
+ iex> ~P"alfcl davis portr"
+ ["place-alfcl", "place-davis", "place-portr"]
+ ```
+ """
+ defmacro sigil_P({:<<>>, _meta, [term]}, _modifiers) when is_binary(term) do
+ case String.split(term) do
+ [place_id] -> "place-#{place_id}"
+ place_ids -> :lists.map(&"place-#{&1}", place_ids)
+ end
+ end
+end
diff --git a/test/support/subway_tagged_stop_sequences.ex b/test/support/subway_tagged_stop_sequences.ex
new file mode 100644
index 000000000..919bf2fbb
--- /dev/null
+++ b/test/support/subway_tagged_stop_sequences.ex
@@ -0,0 +1,71 @@
+defmodule Screens.TestSupport.SubwayTaggedStopSequences do
+ @moduledoc """
+ Functions providing tagged stop sequences for building subway-related test data.
+ """
+
+ import Screens.TestSupport.ParentStationIdSigil
+
+ @spec blue() :: %{Route.id() => [[Stop.id()]]}
+ def blue do
+ %{"Blue" => [~P[wondl rbmnl bmmnl sdmnl orhte wimnl aport mvbcl aqucl state gover bomnl]]}
+ end
+
+ @spec orange() :: %{Route.id() => [[Stop.id()]]}
+ def orange do
+ %{
+ "Orange" => [
+ ~P[ogmnl mlmnl welln astao sull ccmnl north haecl state dwnxg chncl tumnl bbsta masta rugg rcmnl jaksn sbmnl grnst forhl]
+ ]
+ }
+ end
+
+ @spec red(list(atom())) :: %{Route.id() => [[Stop.id()]]}
+ def red(branches \\ ~w[ashmont braintree]a) do
+ [
+ :ashmont in branches and ashmont_seq(),
+ :braintree in branches and braintree_seq()
+ ]
+ |> Enum.filter(& &1)
+ |> then(&%{"Red" => &1})
+ end
+
+ @spec green(list(atom())) :: %{Route.id() => [[Stop.id()]]}
+ def green(branches \\ ~w[b c d e]a) do
+ [
+ :b in branches and {"Green-B", [b_seq()]},
+ :c in branches and {"Green-C", [c_seq()]},
+ :d in branches and {"Green-D", [d_seq()]},
+ :e in branches and {"Green-E", [e_seq()]}
+ ]
+ |> Enum.filter(& &1)
+ |> Map.new()
+ end
+
+ defp ashmont_seq do
+ red_trunk_seq() ++ ~P[shmnl fldcr smmnl asmnl]
+ end
+
+ defp braintree_seq do
+ red_trunk_seq() ++ ~P[nqncy wlsta qnctr qamnl brntn]
+ end
+
+ defp red_trunk_seq do
+ ~P[alfcl davis portr harsq cntsq knncl chmnl pktrm dwnxg sstat brdwy andrw jfk]
+ end
+
+ defp b_seq do
+ ~P[gover pktrm boyls armnl coecl hymnl kencl bland buest bucen amory babck brico harvd grigg alsgr wrnst wascm sthld chswk chill sougr lake]
+ end
+
+ defp c_seq do
+ ~P[gover pktrm boyls armnl coecl hymnl kencl smary hwsst kntst stpul cool sumav bndhl fbkst bcnwa tapst denrd engav clmnl]
+ end
+
+ defp d_seq do
+ ~P[unsqu lech spmnl north haecl gover pktrm boyls armnl coecl hymnl kencl fenwy longw bvmnl brkhl bcnfd rsmnl chhil newto newtn eliot waban woodl river]
+ end
+
+ defp e_seq do
+ ~P[mdftf balsq mgngl gilmn esomr lech spmnl north haecl gover pktrm boyls armnl coecl prmnl symcl nuniv mfa lngmd brmnl fenwd mispk rvrwy bckhl hsmnl]
+ end
+end