diff --git a/lib/content/message/predictions.ex b/lib/content/message/predictions.ex index 80958c495..abc48223d 100644 --- a/lib/content/message/predictions.ex +++ b/lib/content/message/predictions.ex @@ -30,7 +30,8 @@ defmodule Content.Message.Predictions do width: 18, platform: nil, new_cars?: false, - terminal?: false + terminal?: false, + certainty: nil ] @type t :: %__MODULE__{ @@ -45,7 +46,8 @@ defmodule Content.Message.Predictions do station_code: String.t() | nil, zone: String.t() | nil, platform: Content.platform() | nil, - terminal?: boolean() + terminal?: boolean(), + certainty: non_neg_integer() | nil } @spec non_terminal( @@ -61,6 +63,11 @@ defmodule Content.Message.Predictions do # e.g., North Station which is non-terminal but has trips that begin there predicted_time = prediction.seconds_until_arrival || prediction.seconds_until_departure + certainty = + if prediction.seconds_until_arrival, + do: prediction.arrival_certainty, + else: prediction.departure_certainty + minutes = cond do prediction.stops_away == 0 -> :boarding @@ -87,7 +94,8 @@ defmodule Content.Message.Predictions do new_cars?: prediction.new_cars?, station_code: station_code, zone: zone, - platform: platform + platform: platform, + certainty: certainty } {:error, _} -> @@ -125,7 +133,8 @@ defmodule Content.Message.Predictions do direction_id: prediction.direction_id, width: width, new_cars?: prediction.new_cars?, - terminal?: true + terminal?: true, + certainty: prediction.departure_certainty } {:error, _} -> diff --git a/lib/content/message/stopped_train.ex b/lib/content/message/stopped_train.ex index a54f3175d..78575bfbd 100644 --- a/lib/content/message/stopped_train.ex +++ b/lib/content/message/stopped_train.ex @@ -13,11 +13,12 @@ defmodule Content.Message.StoppedTrain do require Logger @enforce_keys [:destination, :stops_away] - defstruct @enforce_keys + defstruct @enforce_keys ++ [:certainty] @type t :: %__MODULE__{ destination: PaEss.destination(), - stops_away: non_neg_integer() + stops_away: non_neg_integer(), + certainty: non_neg_integer() | nil } @spec from_prediction(Predictions.Prediction.t()) :: t() | nil @@ -32,7 +33,8 @@ defmodule Content.Message.StoppedTrain do %__MODULE__{ destination: destination, - stops_away: stops_away + stops_away: stops_away, + certainty: prediction.arrival_certainty || prediction.departure_certainty } {:error, _} -> diff --git a/lib/engine/scheduled_headways.ex b/lib/engine/scheduled_headways.ex index 6a416e8c8..db03ab961 100644 --- a/lib/engine/scheduled_headways.ex +++ b/lib/engine/scheduled_headways.ex @@ -96,6 +96,13 @@ defmodule Engine.ScheduledHeadways do :ets.select(table_name, pattern) end + @impl true + def get_first_scheduled_departure(stop_ids) do + get_first_last_departures(stop_ids) + |> Enum.map(&elem(&1, 0)) + |> min_time() + end + @doc "Checks if the given time is after the first scheduled stop and before the last. A buffer of minutes (positive) is subtracted from the first time. so that headways are shown for a short time before the first train." diff --git a/lib/engine/scheduled_headways_api.ex b/lib/engine/scheduled_headways_api.ex index 2c6a0b310..56b91c3d3 100644 --- a/lib/engine/scheduled_headways_api.ex +++ b/lib/engine/scheduled_headways_api.ex @@ -1,3 +1,4 @@ defmodule Engine.ScheduledHeadwaysAPI do @callback display_headways?([String.t()], DateTime.t(), non_neg_integer()) :: boolean() + @callback get_first_scheduled_departure([binary]) :: nil | DateTime.t() end diff --git a/lib/predictions/prediction.ex b/lib/predictions/prediction.ex index f7e775ba8..0caa44e8a 100644 --- a/lib/predictions/prediction.ex +++ b/lib/predictions/prediction.ex @@ -1,7 +1,9 @@ defmodule Predictions.Prediction do defstruct stop_id: nil, seconds_until_arrival: nil, + arrival_certainty: nil, seconds_until_departure: nil, + departure_certainty: nil, seconds_until_passthrough: nil, direction_id: nil, schedule_relationship: nil, @@ -20,7 +22,9 @@ defmodule Predictions.Prediction do @type t :: %__MODULE__{ stop_id: String.t(), seconds_until_arrival: non_neg_integer() | nil, + arrival_certainty: non_neg_integer() | nil, seconds_until_departure: non_neg_integer() | nil, + departure_certainty: non_neg_integer() | nil, seconds_until_passthrough: non_neg_integer() | nil, direction_id: 0 | 1, schedule_relationship: :scheduled | :skipped | nil, diff --git a/lib/predictions/predictions.ex b/lib/predictions/predictions.ex index 15a13eac2..fb2377f34 100644 --- a/lib/predictions/predictions.ex +++ b/lib/predictions/predictions.ex @@ -96,7 +96,9 @@ defmodule Predictions.Predictions do stop_id: stop_time_update["stop_id"], direction_id: direction_id, seconds_until_arrival: max(0, seconds_until_arrival), + arrival_certainty: stop_time_update["arrival"]["uncertainty"], seconds_until_departure: max(0, seconds_until_departure), + departure_certainty: stop_time_update["departure"]["uncertainty"], seconds_until_passthrough: max(0, seconds_until_passthrough), schedule_relationship: translate_schedule_relationship(stop_time_update["schedule_relationship"]), diff --git a/lib/signs/realtime.ex b/lib/signs/realtime.ex index b60615650..98649e9ce 100644 --- a/lib/signs/realtime.ex +++ b/lib/signs/realtime.ex @@ -24,7 +24,6 @@ defmodule Signs.Realtime do :last_departure_engine, :config_engine, :alerts_engine, - :current_time_fn, :sign_updater, :last_update, :tick_audit, @@ -35,6 +34,7 @@ defmodule Signs.Realtime do defstruct @enforce_keys ++ [ :headway_stop_id, + :current_time_fn, announced_arrivals: [], announced_approachings: [], announced_passthroughs: [], @@ -123,6 +123,22 @@ defmodule Signs.Realtime do sign_config = sign.config_engine.sign_config(sign.id) current_time = sign.current_time_fn.() + first_scheduled_departures = + case sign.source_config do + {top, bottom} -> + { + {sign.headway_engine.get_first_scheduled_departure(SourceConfig.sign_stop_ids(top)), + top.headway_destination}, + {sign.headway_engine.get_first_scheduled_departure( + SourceConfig.sign_stop_ids(bottom) + ), bottom.headway_destination} + } + + source -> + {sign.headway_engine.get_first_scheduled_departure(sign_stop_ids), + source.headway_destination} + end + predictions = case sign.source_config do {top, bottom} -> {fetch_predictions(top, sign), fetch_predictions(bottom, sign)} @@ -135,7 +151,8 @@ defmodule Signs.Realtime do sign, sign_config, current_time, - alert_status + alert_status, + first_scheduled_departures ) sign = diff --git a/lib/signs/utilities/early_am_suppression.ex b/lib/signs/utilities/early_am_suppression.ex new file mode 100644 index 000000000..07f01ba1e --- /dev/null +++ b/lib/signs/utilities/early_am_suppression.ex @@ -0,0 +1,325 @@ +defmodule Signs.Utilities.EarlyAmSuppression do + @moduledoc """ + This module is responsible for handling early AM content. + + When a sign is in full early AM suppression (more than 40 minutes before the first scheduled departure), + the sign will display a two-line message telling riders when to expect the first scheduled train. + Mezzanine signs will page between two two-line messages for either direction. + Alerts, headway mode, custom text mode, and off modes will override early AM suppression. + + When a sign is in partial early AM suppression (less than 40 minutes before the first scheduled departure), + the sign will show predictions if they are valid, meaning that the certainty is <= 120. This prevents + reverse predictions from being shown during early AM hours with the exceptions of Symhony and Prudential EB + because while Heath St is technically a terminal, trains only use it as a turnaround. + + If there are no valid predictions but the amount of time until the first scheduled departure is + less than the upper headway range for that stop, the sign will default to headways. Otherwise, it will fall + back to the timestamp message. Similar logic is applied to either line of a mezzanine sign but the full sign + may either do two-line paging or use single-line timestamp messages/paging headways depending on the contents. + """ + alias Content.Message + alias Content.Message.Headways + alias Content.Message.EarlyAm + + @early_am_start ~T[03:29:00] + @early_am_buffer -40 + @reverse_prediction_certainty 360 + + def do_early_am_suppression( + messages, + current_time, + early_am_status, + schedule, + sign + ) do + case early_am_status do + {_, _} -> + {top_content, bottom_content} = + get_mezzanine_early_am_content( + messages, + sign, + schedule, + early_am_status, + current_time + ) + + cond do + # Special case logic for JFK/UMass Mezzanine + match?( + {%Message.Predictions{station_code: "RJFK", zone: "m"}, _}, + bottom_content + ) -> + cond do + # When JFK/UMass top line (SB) wants to show either timestamp or headways, do full-page paging + match?({%EarlyAm.DestinationTrain{}, _}, top_content) or + match?({%Headways.Top{}, _}, top_content) -> + {bottom, _} = bottom_content + + paginate( + top_content, + # Make zone nil in order to prevent the usual paging platform message + {%{bottom | zone: nil}, + %Message.PlatformPredictionBottom{ + stop_id: bottom.stop_id, + minutes: bottom.minutes + }} + ) + + true -> + {top, _} = top_content + {bottom, _} = bottom_content + {top, bottom} + end + + match?({%Message.Predictions{}, _}, top_content) or + match?({%Message.StoppedTrain{}, _}, top_content) -> + {top, _} = top_content + {top, map_to_single_line_content(bottom_content)} + + match?({%Message.Predictions{}, _}, bottom_content) or + match?({%Message.StoppedTrain{}, _}, bottom_content) -> + {bottom, _} = bottom_content + + case map_to_single_line_content(top_content) do + %EarlyAm.DestinationScheduledTime{} = top -> + {bottom, top} + + %Headways.Paging{} = top -> + {bottom, top} + + top -> + {top, bottom} + end + + match?({%Message.GenericPaging{}, _}, top_content) and + match?({%Message.GenericPaging{}, _}, bottom_content) -> + {top, _} = top_content + {bottom, _} = bottom_content + {top, bottom} + + match?({{%Headways.Top{}, _}, {%Headways.Top{}, _}}, {top_content, bottom_content}) -> + routes = + Signs.Utilities.SourceConfig.sign_routes(sign.source_config) + |> PaEss.Utilities.get_unique_routes() + + {t1, t2} = top_content + {%{t1 | routes: routes}, t2} + + true -> + paginate(top_content, bottom_content) + end + + status -> + get_early_am_content( + sign, + messages, + schedule, + status, + current_time + ) + end + end + + defp get_mezzanine_early_am_content( + messages, + sign, + schedule, + statuses, + current_time + ) do + {top_scheduled, bottom_scheduled} = schedule + + {top_message, bottom_message} = + case messages do + # JFK/UMass case + {%Message.GenericPaging{messages: [prediction, headway_top]}, + %Message.GenericPaging{messages: [_, headway_bottom]}} -> + # Unpack the generic paging message from the normal content generation + # - The headway top message will get filtered out and headways will be re-fetched + # - The prediction can now be filtered out or trigger the special case logic in + # do_early_am_suppression since we reset the zone to "m" and the generic paging + # will be reconstructed + {%Message.Headways.Paging{ + destination: headway_top.destination, + range: headway_bottom.range + }, %{prediction | zone: "m"}} + + _ -> + messages + end + + {top_status, bottom_status} = statuses + + Enum.map( + [ + {top_message, top_scheduled, top_status}, + {bottom_message, bottom_scheduled, bottom_status} + ], + fn {message, scheduled, status} -> + if(status == :none, + do: {message, %Message.Empty{}}, + else: get_early_am_content(sign, {message}, scheduled, status, current_time) + ) + |> case do + # If line has status :none, then it could be a paging headway message + {%Headways.Paging{destination: destination, range: range}, _} -> + {%Headways.Top{destination: destination, vehicle_type: :train}, + %Headways.Bottom{range: range}} + + content -> + content + end + end + ) + |> List.to_tuple() + end + + defp get_early_am_content( + sign, + messages, + {scheduled, destination}, + status, + current_time + ) do + cond do + status == :fully_suppressed -> + {%EarlyAm.DestinationTrain{destination: destination}, + %EarlyAm.ScheduledTime{ + scheduled_time: scheduled + }} + + status == :partially_suppressed -> + case filter_early_am_messages(messages, sign.id) do + [] -> + # If no valid predictions, try fetching headways + case Signs.Utilities.Headways.get_messages(sign, current_time) do + # If no headways are returned, default to timestamp message + {%Message.Empty{}, %Message.Empty{}} -> + {%EarlyAm.DestinationTrain{ + destination: destination + }, + %EarlyAm.ScheduledTime{ + scheduled_time: scheduled + }} + + {headway_top, headway_bottom} -> + # Make sure routes is nil so that destination is used as headsign + {%{headway_top | destination: destination, routes: nil}, headway_bottom} + end + + [message] -> + {message, %Content.Message.Empty{}} + + messages -> + List.to_tuple(messages) + end + end + end + + defp filter_early_am_messages(messages, sign_id) do + Enum.reject(Tuple.to_list(messages), fn message -> + case message do + %Headways.Top{} -> + true + + %Headways.Bottom{} -> + true + + %Message.Empty{} -> + true + + _ -> + is_prediction_or_stopped? = + case message do + %Message.Predictions{} -> true + %Message.StoppedTrain{} -> true + _ -> false + end + + is_prediction_or_stopped? and message.certainty == @reverse_prediction_certainty and + sign_id not in ["symphony_eastbound", "prudential_eastbound"] + end + end) + end + + defp map_to_single_line_content(message) do + case message do + {%EarlyAm.DestinationTrain{} = m1, m2} -> + %EarlyAm.DestinationScheduledTime{ + destination: m1.destination, + scheduled_time: m2.scheduled_time + } + + {%Headways.Top{} = m1, m2} -> + %Headways.Paging{destination: m1.destination, range: m2.range} + + {m1, _} -> + m1 + end + end + + defp paginate(top_content, bottom_content) do + [top, bottom] = Enum.zip(Tuple.to_list(top_content), Tuple.to_list(bottom_content)) + + {%Message.GenericPaging{ + messages: Tuple.to_list(top) + }, + %Message.GenericPaging{ + messages: Tuple.to_list(bottom) + }} + end + + def get_early_am_state( + current_time, + {{_, _} = top_first_scheduled, {_, _} = bottom_first_scheduled} + ) do + { + get_early_am_state(current_time, top_first_scheduled), + get_early_am_state(current_time, bottom_first_scheduled) + } + end + + def get_early_am_state(current_time, {first_scheduled_departure, _dest}) do + cond do + full_early_am_suppression?(current_time, first_scheduled_departure) -> + :fully_suppressed + + partial_early_am_suppression?(current_time, first_scheduled_departure) -> + :partially_suppressed + + true -> + :none + end + end + + defp full_early_am_suppression?(current_time, first_scheduled_departure) do + after_am_suppression_start?(current_time) and + before_am_suppression_end?(current_time, first_scheduled_departure) + end + + defp partial_early_am_suppression?(current_time, first_scheduled_departure) do + not before_am_suppression_end?(current_time, first_scheduled_departure) and + before_scheduled_start?(current_time, first_scheduled_departure) + end + + defp after_am_suppression_start?(current_time) do + Time.compare(DateTime.to_time(current_time), @early_am_start) == :gt + end + + defp before_am_suppression_end?(current_time, first_scheduled_departure) + when not is_nil(first_scheduled_departure) do + DateTime.compare( + current_time, + first_scheduled_departure |> Timex.shift(minutes: @early_am_buffer) + ) == :lt + end + + defp before_am_suppression_end?(_, _), do: false + + defp before_scheduled_start?(current_time, first_scheduled_departure) + when not is_nil(first_scheduled_departure) do + DateTime.compare(current_time, first_scheduled_departure) == :lt + end + + defp before_scheduled_start?(_, _), do: false +end diff --git a/lib/signs/utilities/messages.ex b/lib/signs/utilities/messages.ex index f3654fe22..a4c61e6e2 100644 --- a/lib/signs/utilities/messages.ex +++ b/lib/signs/utilities/messages.ex @@ -11,9 +11,17 @@ defmodule Signs.Utilities.Messages do Signs.Realtime.t(), Engine.Config.sign_config(), DateTime.t(), - Engine.Alerts.Fetcher.stop_status() + Engine.Alerts.Fetcher.stop_status(), + DateTime.t() | {DateTime.t(), DateTime.t()} ) :: Signs.Realtime.sign_messages() - def get_messages(predictions, sign, sign_config, current_time, alert_status) do + def get_messages( + predictions, + sign, + sign_config, + current_time, + alert_status, + scheduled + ) do messages = cond do match?({:static_text, {_, _}}, sign_config) -> @@ -52,11 +60,39 @@ defmodule Signs.Utilities.Messages do end end + early_am_status = + Signs.Utilities.EarlyAmSuppression.get_early_am_state(current_time, scheduled) + flip? = flip?(messages) - if flip?, - do: do_flip(messages), - else: messages + messages = + if flip?, + do: do_flip(messages), + else: messages + + cond do + early_am_status in [:none, {:none, :none}] -> + messages + + alert_status in [:none, :alert_along_route] and sign_config == :auto -> + {early_am_status, scheduled} = + if Signs.Utilities.SourceConfig.multi_source?(sign.source_config) and flip? do + {do_flip(early_am_status), do_flip(scheduled)} + else + {early_am_status, scheduled} + end + + Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + messages, + current_time, + early_am_status, + scheduled, + sign + ) + + true -> + messages + end end defp flip?(messages) do @@ -64,9 +100,6 @@ defmodule Signs.Utilities.Messages do {%Content.Message.Headways.Paging{}, _} -> true - {%Content.Message.Empty{}, _} -> - true - _ -> false end diff --git a/priv/signs.json b/priv/signs.json index d3b685808..284043858 100644 --- a/priv/signs.json +++ b/priv/signs.json @@ -1562,7 +1562,7 @@ ], "source_config": { "headway_group": "orange_trunk", - "headway_direction_name": null, + "headway_direction_name": "Forest Hills", "sources": [ { "stop_id": "70032", diff --git a/test/content/audio/first_scheduled_train_test.exs b/test/content/audio/first_train_scheduled_test.exs similarity index 100% rename from test/content/audio/first_scheduled_train_test.exs rename to test/content/audio/first_train_scheduled_test.exs diff --git a/test/signs/realtime_test.exs b/test/signs/realtime_test.exs index 98af4e65d..326321ebb 100644 --- a/test/signs/realtime_test.exs +++ b/test/signs/realtime_test.exs @@ -190,6 +190,7 @@ defmodule Signs.RealtimeTest do stub(Engine.Alerts.Mock, :max_stop_status, fn _, _ -> :none end) stub(Engine.Predictions.Mock, :for_stop, fn _, _ -> [] end) stub(Engine.ScheduledHeadways.Mock, :display_headways?, fn _, _, _ -> true end) + stub(Engine.ScheduledHeadways.Mock, :get_first_scheduled_departure, fn _ -> nil end) :ok end diff --git a/test/signs/utilities/early_am_suppression_test.exs b/test/signs/utilities/early_am_suppression_test.exs new file mode 100644 index 000000000..892a61e72 --- /dev/null +++ b/test/signs/utilities/early_am_suppression_test.exs @@ -0,0 +1,520 @@ +defmodule Signs.Utilities.EarlyAmSuppresionTest do + use ExUnit.Case, async: true + + defmodule FakeHeadways do + def display_headways?(_stop_ids, _time, _buffer), do: true + end + + defmodule FakeNoHeadway do + def display_headways?(_stop_ids, _time, _buffer), do: false + end + + defmodule FakeConfigEngine do + def headway_config("red_trunk", _time) do + %Engine.Config.Headway{headway_id: "id", range_low: 8, range_high: 10} + end + end + + @src1 %Signs.Utilities.SourceConfig{ + stop_id: "1", + direction_id: 0, + platform: nil, + terminal?: false, + announce_arriving?: false, + announce_boarding?: false, + routes: ["Red"] + } + + @src2 %Signs.Utilities.SourceConfig{ + stop_id: "2", + direction_id: 1, + platform: nil, + terminal?: false, + announce_arriving?: false, + announce_boarding?: false + } + + @platform_sign %Signs.Realtime{ + id: "platform_sign_id", + text_id: {"TEST", "x"}, + audio_id: {"TEST", ["x"]}, + source_config: %{ + sources: [@src1], + headway_destination: :southbound, + headway_group: "red_trunk" + }, + current_content_top: %Content.Message.Predictions{destination: :braintree, minutes: 3}, + current_content_bottom: %Content.Message.Predictions{destination: :ashmont, minutes: 4}, + prediction_engine: nil, + headway_engine: FakeHeadways, + last_departure_engine: nil, + config_engine: FakeConfigEngine, + alerts_engine: nil, + sign_updater: nil, + tick_audit: 240, + tick_read: 1, + read_period_seconds: 100, + last_update: nil + } + + @mezzanine_sign %Signs.Realtime{ + id: "mezzanine_sign_id", + text_id: {"TEST", "x"}, + audio_id: {"TEST", ["x"]}, + source_config: + {%{sources: [@src2], headway_group: "red_trunk", headway_destination: :southbound}, + %{sources: [@src1], headway_group: "red_trunk", headway_destination: :alewife}}, + current_content_top: nil, + current_content_bottom: nil, + prediction_engine: nil, + headway_engine: FakeHeadways, + last_departure_engine: nil, + config_engine: FakeConfigEngine, + alerts_engine: nil, + sign_updater: nil, + tick_audit: 240, + tick_read: 1, + read_period_seconds: 100, + last_update: nil + } + @current_time ~U[2023-07-14 08:00:00Z] + + describe("do_early_am_suppression/5 platform cases") do + @schedule {~U[2023-07-14 09:00:00Z], :southbound} + test "When sign in full am suppression, show timestamp" do + current_content = + {%Content.Message.Predictions{destination: :braintree, minutes: 3}, + %Content.Message.Empty{}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + :fully_suppressed, + @schedule, + @platform_sign + ) == + {%Content.Message.EarlyAm.DestinationTrain{destination: :southbound}, + %Content.Message.EarlyAm.ScheduledTime{ + scheduled_time: ~U[2023-07-14 09:00:00Z] + }} + end + + test "When sign in partial am suppression shows mid-trip and terminal predictions" do + current_content = + {%Content.Message.Predictions{destination: :braintree, minutes: 3, certainty: 60}, + %Content.Message.Predictions{destination: :braintree, minutes: 4, certainty: 120}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + :partially_suppressed, + @schedule, + @platform_sign + ) == + {%Content.Message.Predictions{destination: :braintree, minutes: 3, certainty: 60}, + %Content.Message.Predictions{destination: :braintree, minutes: 4, certainty: 120}} + end + + test "When sign in partial am suppression, filters out reverse predictions" do + current_content = + {%Content.Message.Predictions{destination: :braintree, minutes: 3, certainty: 360}, + %Content.Message.Predictions{destination: :braintree, minutes: 4, certainty: 120}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + :partially_suppressed, + @schedule, + @platform_sign + ) == + {%Content.Message.Predictions{destination: :braintree, minutes: 4, certainty: 120}, + %Content.Message.Empty{}} + end + + test "When sign in partial am suppression, no valid predictions, and within range of upper headway, show headways" do + current_content = + {%Content.Message.Predictions{destination: :braintree, minutes: 3, certainty: 360}, + %Content.Message.Empty{}} + + current_time = ~U[2023-07-14 08:51:00Z] + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + current_time, + :partially_suppressed, + @schedule, + @platform_sign + ) == + {%Content.Message.Headways.Top{destination: :southbound, vehicle_type: :train}, + %Content.Message.Headways.Bottom{prev_departure_mins: nil, range: {8, 10}}} + end + + test "When sign in partial am suppression, no valid predictions, but not within range of upper headway, show timestamp" do + current_content = + {%Content.Message.Predictions{destination: :braintree, minutes: 3, certainty: 360}, + %Content.Message.Empty{}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + :partially_suppressed, + @schedule, + %{@platform_sign | headway_engine: FakeNoHeadway} + ) == + {%Content.Message.EarlyAm.DestinationTrain{destination: :southbound}, + %Content.Message.EarlyAm.ScheduledTime{scheduled_time: ~U[2023-07-14 09:00:00Z]}} + end + + test "Stopped train messages get filtered based on certainty" do + current_content = + {%Content.Message.StoppedTrain{destination: :braintree, stops_away: 3, certainty: 360}, + %Content.Message.Empty{}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + :partially_suppressed, + @schedule, + %{@platform_sign | headway_engine: FakeNoHeadway} + ) == + {%Content.Message.EarlyAm.DestinationTrain{destination: :southbound}, + %Content.Message.EarlyAm.ScheduledTime{scheduled_time: ~U[2023-07-14 09:00:00Z]}} + end + end + + describe "do_early_am_suppression/5 mezzanine cases" do + @schedule {{~U[2023-07-14 08:00:00Z], :alewife}, {~U[2023-07-14 09:00:00Z], :southbound}} + test "Both lines in full am suppression" do + current_content = + {%Content.Message.Predictions{destination: :alewife, minutes: 3}, + %Content.Message.Predictions{destination: :braintree, minutes: 4}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + {:fully_suppressed, :fully_suppressed}, + @schedule, + @mezzanine_sign + ) == + { + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.EarlyAm.DestinationTrain{destination: :alewife}, + %Content.Message.EarlyAm.DestinationTrain{destination: :southbound} + ] + }, + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.EarlyAm.ScheduledTime{ + scheduled_time: ~U[2023-07-14 08:00:00Z] + }, + %Content.Message.EarlyAm.ScheduledTime{ + scheduled_time: ~U[2023-07-14 09:00:00Z] + } + ] + } + } + end + + test "One line in full am suppression, one line in partial am suppression defaulting to headways" do + current_content = + {%Content.Message.Predictions{destination: :alewife, minutes: 3}, + %Content.Message.Predictions{destination: :braintree, minutes: 4, certainty: 360}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + {:fully_suppressed, :partially_suppressed}, + @schedule, + @mezzanine_sign + ) == + { + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.EarlyAm.DestinationTrain{destination: :alewife}, + %Content.Message.Headways.Top{ + destination: :southbound, + vehicle_type: :train + } + ] + }, + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.EarlyAm.ScheduledTime{ + scheduled_time: ~U[2023-07-14 08:00:00Z] + }, + %Content.Message.Headways.Bottom{prev_departure_mins: nil, range: {8, 10}} + ] + } + } + end + + test "Both lines in partial am suppression defaulting to headways" do + current_content = + {%Content.Message.Predictions{destination: :alewife, minutes: 3, certainty: 360}, + %Content.Message.Predictions{destination: :braintree, minutes: 4, certainty: 360}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + {:partially_suppressed, :partially_suppressed}, + @schedule, + @mezzanine_sign + ) == + {%Content.Message.Headways.Top{ + destination: :alewife, + vehicle_type: :train, + routes: ["Red"] + }, %Content.Message.Headways.Bottom{prev_departure_mins: nil, range: {8, 10}}} + end + + test "One line showing prediction, one line default to paging headway" do + current_content = + {%Content.Message.Predictions{destination: :alewife, minutes: 3, certainty: 360}, + %Content.Message.Predictions{destination: :braintree, minutes: 4, certainty: 60}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + {:partially_suppressed, :partially_suppressed}, + @schedule, + @mezzanine_sign + ) == + {%Content.Message.Predictions{ + destination: :braintree, + certainty: 60, + minutes: 4 + }, %Content.Message.Headways.Paging{range: {8, 10}, destination: :alewife}} + end + + test "One line showing prediction, one line showing timestamp" do + current_content = + {%Content.Message.Predictions{destination: :alewife, minutes: 3, certainty: 360}, + %Content.Message.Predictions{destination: :braintree, minutes: 4, certainty: 60}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + {:partially_suppressed, :partially_suppressed}, + @schedule, + %{@mezzanine_sign | headway_engine: FakeNoHeadway} + ) == + {%Content.Message.Predictions{ + destination: :braintree, + certainty: 60, + minutes: 4 + }, + %Content.Message.EarlyAm.DestinationScheduledTime{ + destination: :alewife, + scheduled_time: ~U[2023-07-14 08:00:00Z] + }} + end + + test "Both lines showing prediction" do + current_content = + {%Content.Message.Predictions{destination: :alewife, minutes: 3, certainty: 60}, + %Content.Message.Predictions{destination: :braintree, minutes: 4, certainty: 60}} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + {:partially_suppressed, :partially_suppressed}, + @schedule, + @mezzanine_sign + ) == + {%Content.Message.Predictions{ + destination: :alewife, + certainty: 60, + minutes: 3 + }, + %Content.Message.Predictions{ + destination: :braintree, + certainty: 60, + minutes: 4 + }} + end + end + + describe "do_early_am_suppression/5 JFK/UMass mezzanine special cases" do + @schedule {{~U[2023-07-14 09:00:00Z], :southbound}, {~U[2023-07-14 09:00:00Z], :alewife}} + + test "Southbound on timestamp and Alewife on platform prediction" do + current_content = + {%Content.Message.Predictions{destination: :braintree, minutes: 3, certainty: 360}, + %Content.Message.Predictions{ + destination: :alewife, + minutes: 4, + certainty: 60, + station_code: "RJFK", + zone: "m" + }} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + {:partially_suppressed, :partially_suppressed}, + @schedule, + %{@mezzanine_sign | headway_engine: FakeNoHeadway} + ) == + { + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.EarlyAm.DestinationTrain{destination: :southbound}, + %Content.Message.Predictions{ + certainty: 60, + destination: :alewife, + minutes: 4, + station_code: "RJFK", + zone: nil + } + ] + }, + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.EarlyAm.ScheduledTime{ + scheduled_time: ~U[2023-07-14 09:00:00Z] + }, + %Content.Message.PlatformPredictionBottom{minutes: 4, stop_id: nil} + ] + } + } + end + + test "Southbound on headways and Alewife on platform prediction" do + current_content = + {%Content.Message.Predictions{destination: :braintree, minutes: 3, certainty: 360}, + %Content.Message.Predictions{ + destination: :alewife, + minutes: 4, + certainty: 60, + station_code: "RJFK", + zone: "m" + }} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + {:partially_suppressed, :partially_suppressed}, + @schedule, + @mezzanine_sign + ) == + { + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.Headways.Top{ + destination: :southbound, + vehicle_type: :train + }, + %Content.Message.Predictions{ + certainty: 60, + destination: :alewife, + minutes: 4, + station_code: "RJFK", + zone: nil + } + ] + }, + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.Headways.Bottom{prev_departure_mins: nil, range: {8, 10}}, + %Content.Message.PlatformPredictionBottom{minutes: 4, stop_id: nil} + ] + } + } + end + + test "Filtered platform prediction and headways returns full page headways" do + current_content = + {%Content.Message.GenericPaging{ + messages: [ + %Content.Message.Predictions{ + destination: :alewife, + minutes: 3, + certainty: 360, + station_code: "RJFK", + zone: "m" + }, + %Content.Message.Headways.Top{destination: :southbound, vehicle_type: :train} + ] + }, + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.PlatformPredictionBottom{minutes: 3, stop_id: nil}, + %Content.Message.Headways.Bottom{range: {8, 10}} + ] + }} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + {:partially_suppressed, :partially_suppressed}, + @schedule, + @mezzanine_sign + ) == + {%Content.Message.Headways.Top{ + destination: :southbound, + routes: ["Red"], + vehicle_type: :train + }, %Content.Message.Headways.Bottom{prev_departure_mins: nil, range: {8, 10}}} + end + + test "Valid platform prediction and non suppressed headway gets passed through" do + current_content = + {%Content.Message.GenericPaging{ + messages: [ + %Content.Message.Predictions{ + destination: :alewife, + minutes: 3, + certainty: 60, + station_code: "RJFK", + zone: "m" + }, + %Content.Message.Headways.Top{destination: :southbound, vehicle_type: :train} + ] + }, + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.PlatformPredictionBottom{minutes: 3, stop_id: nil}, + %Content.Message.Headways.Bottom{range: {8, 10}} + ] + }} + + assert Signs.Utilities.EarlyAmSuppression.do_early_am_suppression( + current_content, + @current_time, + {:none, :partially_suppressed}, + @schedule, + @mezzanine_sign + ) == + { + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.Headways.Top{ + destination: :southbound, + routes: nil, + vehicle_type: :train + }, + %Content.Message.Predictions{ + certainty: 60, + destination: :alewife, + minutes: 3, + station_code: "RJFK", + width: 18, + zone: nil + } + ] + }, + %Content.Message.GenericPaging{ + messages: [ + %Content.Message.Headways.Bottom{prev_departure_mins: nil, range: {8, 10}}, + %Content.Message.PlatformPredictionBottom{ + destination: nil, + minutes: 3, + stop_id: nil + } + ] + } + } + end + end +end