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