diff --git a/lib/content/audio/service_ended.ex b/lib/content/audio/service_ended.ex new file mode 100644 index 000000000..1627cd9c8 --- /dev/null +++ b/lib/content/audio/service_ended.ex @@ -0,0 +1,80 @@ +defmodule Content.Audio.ServiceEnded do + alias PaEss.Utilities + @enforce_keys [:location] + defstruct @enforce_keys ++ [:destination] + + @type location :: :platform | :station | :direction + @type t :: %__MODULE__{ + destination: PaEss.destination(), + location: location() + } + + def from_message(%Content.Message.LastTrip.StationClosed{}) do + [%__MODULE__{location: :station}] + end + + def from_message(%Content.Message.LastTrip.PlatformClosed{destination: destination}) do + [%__MODULE__{location: :platform, destination: destination}] + end + + def from_message(%Content.Message.LastTrip.NoService{destination: destination}) do + [%__MODULE__{location: :direction, destination: destination}] + end + + defimpl Content.Audio do + @service_ended "882" + @station_closed "883" + @platform_closed "884" + + def to_params(%Content.Audio.ServiceEnded{location: :station}) do + Utilities.take_message([@station_closed], :audio) + end + + def to_params( + %Content.Audio.ServiceEnded{location: :platform, destination: destination} = audio + ) do + case Utilities.destination_var(destination) do + {:ok, destination_var} -> + Utilities.take_message([@platform_closed, destination_var, @service_ended], :audio) + + {:error, :unknown} -> + to_tts(audio) + end + end + + def to_params( + %Content.Audio.ServiceEnded{location: :direction, destination: destination} = audio + ) do + case Utilities.destination_var(destination) do + {:ok, destination_var} -> + Utilities.take_message([destination_var, @service_ended], :audio) + + {:error, :unknown} -> + to_tts(audio) + end + end + + def to_tts(%Content.Audio.ServiceEnded{location: :station}) do + "This station is closed. Service has ended for the night." + end + + def to_tts(%Content.Audio.ServiceEnded{location: :platform, destination: destination}) do + {:ok, destination_string} = Utilities.destination_to_ad_hoc_string(destination) + + service_ended = + "#{destination_string} service has ended for the night." + |> String.trim_leading() + |> String.capitalize() + + "This platform is closed. #{service_ended}" + end + + def to_tts(%Content.Audio.ServiceEnded{location: :direction, destination: destination}) do + {:ok, destination_string} = Utilities.destination_to_ad_hoc_string(destination) + + "#{destination_string} service has ended for the night." + |> String.trim_leading() + |> String.capitalize() + end + end +end diff --git a/lib/content/message/last_trip/no_service.ex b/lib/content/message/last_trip/no_service.ex new file mode 100644 index 000000000..67156b02a --- /dev/null +++ b/lib/content/message/last_trip/no_service.ex @@ -0,0 +1,38 @@ +defmodule Content.Message.LastTrip.NoService do + @enforce_keys [:destination, :page?] + defstruct @enforce_keys + + @type t :: %__MODULE__{ + destination: PaEss.destination(), + page?: boolean() + } + + defimpl Content.Message do + def to_string(%Content.Message.LastTrip.NoService{ + destination: destination, + page?: page? + }) do + headsign = PaEss.Utilities.destination_to_sign_string(destination) + + if page?, + do: [ + {Content.Utilities.width_padded_string( + headsign, + "No trains", + 24 + ), 6}, + {Content.Utilities.width_padded_string( + headsign, + "Svc ended", + 24 + ), 6} + ], + else: + Content.Utilities.width_padded_string( + headsign, + "No Svc", + 18 + ) + end + end +end diff --git a/lib/content/message/last_trip/platform_closed.ex b/lib/content/message/last_trip/platform_closed.ex new file mode 100644 index 000000000..2462f546d --- /dev/null +++ b/lib/content/message/last_trip/platform_closed.ex @@ -0,0 +1,17 @@ +defmodule Content.Message.LastTrip.PlatformClosed do + @moduledoc """ + A message displayed when a station is closed + """ + @enforce_keys [:destination] + defstruct @enforce_keys + + @type t :: %__MODULE__{ + destination: PaEss.destination() + } + + defimpl Content.Message do + def to_string(%Content.Message.LastTrip.PlatformClosed{}) do + "Platform closed" + end + end +end diff --git a/lib/content/message/last_trip/service_ended.ex b/lib/content/message/last_trip/service_ended.ex new file mode 100644 index 000000000..65848dd6c --- /dev/null +++ b/lib/content/message/last_trip/service_ended.ex @@ -0,0 +1,15 @@ +defmodule Content.Message.LastTrip.ServiceEnded do + @moduledoc """ + A message displayed when a station is closed + """ + @enforce_keys [] + defstruct @enforce_keys + + @type t :: %__MODULE__{} + + defimpl Content.Message do + def to_string(%Content.Message.LastTrip.ServiceEnded{}) do + "Service ended for night" + end + end +end diff --git a/lib/content/message/last_trip/station_closed.ex b/lib/content/message/last_trip/station_closed.ex new file mode 100644 index 000000000..3b57bba0a --- /dev/null +++ b/lib/content/message/last_trip/station_closed.ex @@ -0,0 +1,15 @@ +defmodule Content.Message.LastTrip.StationClosed do + @moduledoc """ + A message displayed when a station is closed + """ + @enforce_keys [] + defstruct @enforce_keys + + @type t :: %__MODULE__{} + + defimpl Content.Message do + def to_string(%Content.Message.LastTrip.StationClosed{}) do + "Station closed" + end + end +end diff --git a/lib/engine/last_trip.ex b/lib/engine/last_trip.ex new file mode 100644 index 000000000..af6786faa --- /dev/null +++ b/lib/engine/last_trip.ex @@ -0,0 +1,127 @@ +defmodule Engine.LastTrip do + @behaviour Engine.LastTripAPI + use GenServer + require Logger + + @recent_departures_table :recent_departures + @last_trips_table :last_trips + @hour_in_seconds 3600 + + @type state :: %{ + recent_departures: :ets.tab(), + last_trips: :ets.tab() + } + + def start_link([]) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @impl true + def get_recent_departures(recent_departures_table \\ @recent_departures_table, stop_id) do + case :ets.lookup(recent_departures_table, stop_id) do + [{^stop_id, departures}] -> departures + _ -> [] + end + end + + @impl true + def is_last_trip?(last_trips_table \\ @last_trips_table, trip_id) do + case :ets.lookup(last_trips_table, trip_id) do + [{^trip_id, _timestamp}] -> true + _ -> false + end + end + + def update_last_trips(last_trips) do + GenServer.cast(__MODULE__, {:update_last_trips, last_trips}) + end + + def update_recent_departures(new_recent_departures) do + GenServer.cast(__MODULE__, {:update_recent_departures, new_recent_departures}) + end + + @impl true + def init(_) do + schedule_clean(self()) + + state = %{ + recent_departures: @recent_departures_table, + last_trips: @last_trips_table + } + + create_tables(state) + {:ok, state} + end + + def create_tables(state) do + :ets.new(state.recent_departures, [:named_table, read_concurrency: true]) + :ets.new(state.last_trips, [:named_table, read_concurrency: true]) + end + + @impl true + def handle_cast({:update_last_trips, last_trips}, %{last_trips: last_trips_table} = state) do + current_time = Timex.now() + + last_trips = Enum.map(last_trips, fn trip_id -> {trip_id, current_time} end) + + :ets.insert(last_trips_table, last_trips) + + {:noreply, state} + end + + @impl true + def handle_cast( + {:update_recent_departures, new_recent_departures}, + %{recent_departures: recent_departures_table} = state + ) do + current_recent_departures = :ets.tab2list(recent_departures_table) |> Map.new() + + Enum.reduce(new_recent_departures, current_recent_departures, fn {stop_id, trip_id, + departure_time}, + acc -> + Map.update(acc, stop_id, %{trip_id => departure_time}, fn recent_departures -> + Map.put(recent_departures, trip_id, departure_time) + end) + end) + |> Map.to_list() + |> then(&:ets.insert(recent_departures_table, &1)) + + {:noreply, state} + end + + @impl true + def handle_info(:clean_old_data, state) do + schedule_clean(self()) + clean_last_trips(state) + clean_recent_departures(state) + + {:noreply, state} + end + + defp clean_last_trips(state) do + :ets.tab2list(state.last_trips) + |> Enum.each(fn {trip_id, timestamp} -> + if Timex.diff(Timex.now(), timestamp, :seconds) > @hour_in_seconds * 2 do + :ets.delete(state.last_trips, trip_id) + end + end) + end + + defp clean_recent_departures(state) do + current_time = Timex.now() + + :ets.tab2list(state.recent_departures) + |> Enum.each(fn {key, departures} -> + departures_within_last_hour = + Map.filter(departures, fn {_, departed_time} -> + DateTime.to_unix(current_time) - DateTime.to_unix(departed_time) <= @hour_in_seconds + end) + + :ets.insert(state.recent_departures, {key, departures_within_last_hour}) + end) + end + + defp schedule_clean(pid) do + Process.send_after(pid, :clean_old_data, 1_000) + end +end diff --git a/lib/engine/last_trip_api.ex b/lib/engine/last_trip_api.ex new file mode 100644 index 000000000..92c3b6674 --- /dev/null +++ b/lib/engine/last_trip_api.ex @@ -0,0 +1,4 @@ +defmodule Engine.LastTripAPI do + @callback get_recent_departures(String.t()) :: map() + @callback is_last_trip?(String.t()) :: boolean() +end diff --git a/lib/engine/predictions.ex b/lib/engine/predictions.ex index a6407e7a3..36115b9a3 100644 --- a/lib/engine/predictions.ex +++ b/lib/engine/predictions.ex @@ -60,9 +60,16 @@ defmodule Engine.Predictions do recv_timeout: 2000 ) do {:ok, %HTTPoison.Response{body: body, status_code: 200, headers: headers}} -> + parsed_json = Predictions.Predictions.parse_json_response(body) + {new_predictions, vehicles_running_revenue_trips} = - Predictions.Predictions.parse_json_response(body) - |> Predictions.Predictions.get_all(current_time) + Predictions.Predictions.get_all(parsed_json, current_time) + + Predictions.LastTrip.get_last_trips(parsed_json) + |> Engine.LastTrip.update_last_trips() + + Predictions.LastTrip.get_recent_departures(parsed_json) + |> Engine.LastTrip.update_recent_departures() :ets.tab2list(state.trip_updates_table) |> Enum.map(&{elem(&1, 0), []}) diff --git a/lib/predictions/last_trip.ex b/lib/predictions/last_trip.ex new file mode 100644 index 000000000..23d5d1987 --- /dev/null +++ b/lib/predictions/last_trip.ex @@ -0,0 +1,31 @@ +defmodule Predictions.LastTrip do + defp get_running_trips(predictions_feed) do + predictions_feed["entity"] + |> Stream.map(& &1["trip_update"]) + |> Enum.reject(&(&1["trip"]["schedule_relationship"] == "CANCELED")) + end + + def get_last_trips(predictions_feed) do + get_running_trips(predictions_feed) + |> Stream.filter(&(&1["trip"]["last_trip"] == true)) + |> Enum.map(& &1["trip"]["trip_id"]) + end + + def get_recent_departures(predictions_feed) do + predictions_by_trip = + get_running_trips(predictions_feed) + |> Enum.map(&{&1["trip"]["trip_id"], &1["stop_time_update"], &1["vehicle"]["id"]}) + + for {trip_id, predictions, vehicle_id} <- predictions_by_trip, + prediction <- predictions do + vehicle_location = Engine.Locations.for_vehicle(vehicle_id) + + if vehicle_location && + (vehicle_location.stop_id == prediction["stop_id"] and + vehicle_location.status == :stopped_at) do + {prediction["stop_id"], trip_id, Timex.now()} + end + end + |> Enum.reject(&is_nil/1) + end +end diff --git a/lib/realtime_signs.ex b/lib/realtime_signs.ex index 4a7cd5ead..451706cc8 100644 --- a/lib/realtime_signs.ex +++ b/lib/realtime_signs.ex @@ -23,6 +23,7 @@ defmodule RealtimeSigns do Engine.BusPredictions, Engine.ChelseaBridge, Engine.Routes, + Engine.LastTrip, MessageQueue, RealtimeSigns.Scheduler, RealtimeSignsWeb.Endpoint, diff --git a/lib/signs/realtime.ex b/lib/signs/realtime.ex index 9df27c4b8..d8c66b137 100644 --- a/lib/signs/realtime.ex +++ b/lib/signs/realtime.ex @@ -24,6 +24,7 @@ defmodule Signs.Realtime do :headway_engine, :config_engine, :alerts_engine, + :last_trip_engine, :sign_updater, :last_update, :tick_read, @@ -65,6 +66,7 @@ defmodule Signs.Realtime do headway_engine: module(), config_engine: module(), alerts_engine: module(), + last_trip_engine: module(), current_time_fn: fun(), sign_updater: module(), last_update: DateTime.t(), @@ -90,6 +92,7 @@ defmodule Signs.Realtime do headway_engine = opts[:headway_engine] || Engine.ScheduledHeadways config_engine = opts[:config_engine] || Engine.Config alerts_engine = opts[:alerts_engine] || Engine.Alerts + last_trip_engine = opts[:last_trip_engine] || Engine.LastTrip sign_updater = opts[:sign_updater] || Application.get_env(:realtime_signs, :sign_updater_mod) sign = %__MODULE__{ @@ -104,6 +107,7 @@ defmodule Signs.Realtime do headway_engine: headway_engine, config_engine: config_engine, alerts_engine: alerts_engine, + last_trip_engine: last_trip_engine, current_time_fn: opts[:current_time_fn] || fn -> @@ -168,6 +172,16 @@ defmodule Signs.Realtime do {predictions, predictions} end + service_end_statuses_per_source = + case sign.source_config do + {top_source, bottom_source} -> + {has_service_ended_for_source?(sign, top_source, current_time), + has_service_ended_for_source?(sign, bottom_source, current_time)} + + source -> + has_service_ended_for_source?(sign, source, current_time) + end + {new_top, new_bottom} = Utilities.Messages.get_messages( predictions, @@ -175,7 +189,8 @@ defmodule Signs.Realtime do sign_config, current_time, alert_status, - first_scheduled_departures + first_scheduled_departures, + service_end_statuses_per_source ) sign = @@ -196,6 +211,24 @@ defmodule Signs.Realtime do {:noreply, state} end + defp has_service_ended_for_source?(sign, source, current_time) do + if source.headway_group not in ["red_trunk", "red_ashmont", "red_braintree"] do + SourceConfig.sign_stop_ids(source) + |> Enum.count(&has_last_trip_departed_stop?(&1, sign, current_time)) >= 1 + else + false + end + end + + defp has_last_trip_departed_stop?(stop_id, sign, current_time) do + sign.last_trip_engine.get_recent_departures(stop_id) + |> Enum.any?(fn {trip_id, departure_time} -> + # Use a 3 second buffer to make sure trips have fully departed + DateTime.to_unix(current_time) - DateTime.to_unix(departure_time) > 3 and + sign.last_trip_engine.is_last_trip?(trip_id) + end) + end + defp prediction_key(prediction) do Map.take(prediction, [:stop_id, :route_id, :vehicle_id, :direction_id, :trip_id]) end diff --git a/lib/signs/utilities/audio.ex b/lib/signs/utilities/audio.ex index 3a88b74f1..0879b9eea 100644 --- a/lib/signs/utilities/audio.ex +++ b/lib/signs/utilities/audio.ex @@ -91,6 +91,10 @@ defmodule Signs.Utilities.Audio do end end + defp get_passive_readout({:service_ended, top, _}) do + Audio.ServiceEnded.from_message(top) + end + defp get_prediction_readout(%Message.Predictions{minutes: minutes} = prediction) do case minutes do :boarding -> @@ -269,6 +273,7 @@ defmodule Signs.Utilities.Audio do | {:predictions, [Content.Message.t()]} | {:headway, Content.Message.t(), Content.Message.t() | nil} | {:scheduled_train, Content.Message.t(), Content.Message.t() | nil} + | {:service_ended, Content.Message.t(), Content.Message.t() | nil} ] defp decode_sign(sign, top_content, bottom_content) do case {sign, top_content, bottom_content} do @@ -285,6 +290,12 @@ defmodule Signs.Utilities.Audio do {_, %Message.GenericPaging{} = top, %Message.GenericPaging{} = bottom} -> Enum.zip(top.messages, bottom.messages) |> Enum.flat_map(&decode_lines/1) + {_, %Message.LastTrip.PlatformClosed{} = top, bottom} -> + [{:service_ended, top, bottom}] + + {_, %Message.LastTrip.StationClosed{} = top, bottom} -> + [{:service_ended, top, bottom}] + # Mezzanine signs get separate treatment for each half, e.g. they will return two # separate prediction lists with one prediction each. {%Signs.Realtime{source_config: {_, _}}, top, bottom} -> @@ -328,6 +339,7 @@ defmodule Signs.Utilities.Audio do %Message.Alert.DestinationNoService{} -> [{:alert, line, nil}] %Message.Headways.Paging{} -> [{:headway, line, nil}] %Message.EarlyAm.DestinationScheduledTime{} -> [{:scheduled_train, line, nil}] + %Message.LastTrip.NoService{} -> [{:service_ended, line, nil}] _ -> [] end end diff --git a/lib/signs/utilities/last_trip.ex b/lib/signs/utilities/last_trip.ex new file mode 100644 index 000000000..58dc84a26 --- /dev/null +++ b/lib/signs/utilities/last_trip.ex @@ -0,0 +1,112 @@ +defmodule Signs.Utilities.LastTrip do + alias Content.Message + alias Content.Message.LastTrip + + def get_last_trip_messages( + {top_message, bottom_message} = messages, + service_status, + source + ) do + case service_status do + {has_top_service_ended?, has_bottom_service_ended?} -> + {unpacked_mz_top, unpacked_mz_bottom} = unpack_mezzanine_content(messages, source) + {top_source, bottom_source} = source + + cond do + # If combined alert status, only switch to Last Trip messaging once service has fully ended. + # Note: This is a very rare or impossible case because trips wouldn't be running through a closed + # stop so a last trip wouldn't be tracked. But we should account for it just in case. + match?(%Message.Alert.NoService{}, unpacked_mz_top) -> + if has_top_service_ended? and has_bottom_service_ended?, + do: + {%Content.Message.LastTrip.StationClosed{}, + %Content.Message.LastTrip.ServiceEnded{}}, + else: messages + + has_top_service_ended? and has_bottom_service_ended? and + not is_prediction?(unpacked_mz_top) and + not is_prediction?(unpacked_mz_bottom) -> + {%Content.Message.LastTrip.StationClosed{}, %Content.Message.LastTrip.ServiceEnded{}} + + has_top_service_ended? and not is_prediction?(unpacked_mz_top) and + not is_empty?(unpacked_mz_bottom) -> + if get_message_length(unpacked_mz_bottom) <= 18 do + {unpacked_mz_bottom, + %Content.Message.LastTrip.NoService{ + destination: top_source.headway_destination, + page?: true + }} + else + {%Content.Message.LastTrip.NoService{ + destination: top_source.headway_destination, + page?: false + }, unpacked_mz_bottom} + end + + has_bottom_service_ended? and not is_prediction?(unpacked_mz_bottom) and + not is_empty?(unpacked_mz_top) -> + if get_message_length(unpacked_mz_top) <= 18 do + {unpacked_mz_top, + %Content.Message.LastTrip.NoService{ + destination: bottom_source.headway_destination, + page?: true + }} + else + {%Content.Message.LastTrip.NoService{ + destination: bottom_source.headway_destination, + page?: false + }, unpacked_mz_top} + end + + true -> + messages + end + + has_service_ended? -> + if has_service_ended? and + not (is_prediction?(top_message) or is_prediction?(bottom_message)), + do: + {%LastTrip.PlatformClosed{destination: source.headway_destination}, + %LastTrip.ServiceEnded{}}, + else: messages + end + end + + defp unpack_mezzanine_content(messages, {top_source, bottom_source}) do + case messages do + # JFK/UMass case + {%Message.GenericPaging{messages: [prediction, headway_top]}, + %Message.GenericPaging{messages: [_, headway_bottom]}} -> + {%Message.Headways.Paging{ + destination: headway_top.destination, + range: headway_bottom.range + }, %{prediction | zone: "m"}} + + {%Message.Headways.Top{}, %Message.Headways.Bottom{range: range}} -> + {%Message.Headways.Paging{destination: top_source.headway_destination, range: range}, + %Message.Headways.Paging{destination: bottom_source.headway_destination, range: range}} + + _ -> + messages + end + end + + defp is_prediction?(message) do + match?(%Content.Message.Predictions{}, message) + end + + defp is_empty?(message) do + match?(%Content.Message.Empty{}, message) + end + + defp get_message_length(message) do + message_string = Content.Message.to_string(message) + + if is_list(message_string) do + Stream.map(message_string, fn {string, _} -> String.length(string) end) + |> Enum.max() + else + String.length(message_string) + end + end +end diff --git a/lib/signs/utilities/messages.ex b/lib/signs/utilities/messages.ex index 6356b9a5c..6cb27a5a8 100644 --- a/lib/signs/utilities/messages.ex +++ b/lib/signs/utilities/messages.ex @@ -12,7 +12,8 @@ defmodule Signs.Utilities.Messages do Engine.Config.sign_config(), DateTime.t(), Engine.Alerts.Fetcher.stop_status(), - DateTime.t() | {DateTime.t(), DateTime.t()} + DateTime.t() | {DateTime.t(), DateTime.t()}, + boolean() | {boolean(), boolean()} ) :: Signs.Realtime.sign_messages() def get_messages( predictions, @@ -20,7 +21,8 @@ defmodule Signs.Utilities.Messages do sign_config, current_time, alert_status, - scheduled + scheduled, + service_statuses_per_source ) do messages = cond do @@ -67,6 +69,16 @@ defmodule Signs.Utilities.Messages do end end + messages = + if sign_config in [:off, :static_text], + do: messages, + else: + Signs.Utilities.LastTrip.get_last_trip_messages( + messages, + service_statuses_per_source, + sign.source_config + ) + early_am_status = Signs.Utilities.EarlyAmSuppression.get_early_am_state(current_time, scheduled) diff --git a/test/signs/realtime_test.exs b/test/signs/realtime_test.exs index 52eda0c57..5b00d455d 100644 --- a/test/signs/realtime_test.exs +++ b/test/signs/realtime_test.exs @@ -14,6 +14,15 @@ defmodule Signs.RealtimeTest do announce_boarding?: false } + @src_2 %Signs.Utilities.SourceConfig{ + stop_id: "2", + direction_id: 0, + platform: nil, + terminal?: false, + announce_arriving?: true, + announce_boarding?: false + } + @fake_time DateTime.new!(~D[2023-01-01], ~T[12:00:00], "America/New_York") def fake_time_fn, do: @fake_time @@ -33,6 +42,7 @@ defmodule Signs.RealtimeTest do headway_engine: Engine.ScheduledHeadways.Mock, config_engine: Engine.Config.Mock, alerts_engine: Engine.Alerts.Mock, + last_trip_engine: Engine.LastTrip.Mock, current_time_fn: &Signs.RealtimeTest.fake_time_fn/0, sign_updater: PaEss.Updater.Mock, last_update: @fake_time, @@ -44,7 +54,7 @@ defmodule Signs.RealtimeTest do @sign | source_config: { %{sources: [@src], headway_group: "group", headway_destination: :northbound}, - %{sources: [@src], headway_group: "group", headway_destination: :southbound} + %{sources: [@src_2], headway_group: "group", headway_destination: :southbound} }, current_content_top: "Trains", current_content_bottom: "Every 11 to 13 min" @@ -85,6 +95,8 @@ defmodule Signs.RealtimeTest do stub(Engine.Predictions.Mock, :for_stop, fn _, _ -> [] end) stub(Engine.ScheduledHeadways.Mock, :display_headways?, fn _, _, _ -> true end) stub(Engine.Locations.Mock, :for_vehicle, fn _ -> nil end) + stub(Engine.LastTrip.Mock, :is_last_trip?, fn _ -> false end) + stub(Engine.LastTrip.Mock, :get_recent_departures, fn _ -> %{} end) stub(Engine.ScheduledHeadways.Mock, :get_first_scheduled_departure, fn _ -> datetime(~T[05:00:00]) @@ -1321,6 +1333,8 @@ defmodule Signs.RealtimeTest do stub(Engine.Config.Mock, :sign_config, fn _ -> :auto end) stub(Engine.Alerts.Mock, :max_stop_status, fn _, _ -> :shuttles_transfer_station end) stub(Engine.Predictions.Mock, :for_stop, fn _, _ -> [] end) + stub(Engine.LastTrip.Mock, :is_last_trip?, fn _ -> false end) + stub(Engine.LastTrip.Mock, :get_recent_departures, fn _ -> %{} end) stub(Engine.ScheduledHeadways.Mock, :get_first_scheduled_departure, fn _ -> datetime(~T[05:00:00]) @@ -1341,6 +1355,144 @@ defmodule Signs.RealtimeTest do end end + describe "Last Trip of the Day" do + setup do + stub(Engine.Config.Mock, :sign_config, fn _ -> :auto end) + stub(Engine.Alerts.Mock, :max_stop_status, fn _, _ -> :none end) + stub(Engine.Predictions.Mock, :for_stop, fn _, _ -> [] end) + stub(Engine.LastTrip.Mock, :is_last_trip?, fn _ -> true end) + + stub(Engine.LastTrip.Mock, :get_recent_departures, fn _ -> + %{"a" => ~U[2023-01-01 00:00:00.000Z]} + end) + + stub(Engine.Config.Mock, :headway_config, fn _, _ -> @headway_config end) + stub(Engine.ScheduledHeadways.Mock, :display_headways?, fn _, _, _ -> false end) + + stub(Engine.ScheduledHeadways.Mock, :get_first_scheduled_departure, fn _ -> + datetime(~T[05:00:00]) + end) + + :ok + end + + test "Platform is closed" do + sign = %{ + @sign + | tick_read: 0 + } + + expect_messages({"Platform closed", "Service ended for night"}) + expect_audios(canned: {"107", ["884", "21000", "787", "21000", "882"], :audio}) + Signs.Realtime.handle_info(:run_loop, sign) + end + + test "Station is closed" do + sign = %{ + @mezzanine_sign + | tick_read: 0 + } + + expect_messages({"Station closed", "Service ended for night"}) + expect_audios(canned: {"103", ["883"], :audio}) + Signs.Realtime.handle_info(:run_loop, sign) + end + + test "No service goes on bottom line when top line fits in 18 chars or less" do + sign = %{ + @mezzanine_sign + | tick_read: 0, + announced_stalls: [{"a", 8}] + } + + expect(Engine.Predictions.Mock, :for_stop, fn "1", 0 -> + [prediction(destination: :mattapan, arrival: 1100, stopped: 8, trip_id: "a")] + end) + + expect(Engine.Predictions.Mock, :for_stop, fn "2", 0 -> + [] + end) + + expect(Engine.LastTrip.Mock, :get_recent_departures, fn "1" -> + %{"a" => ~U[2023-01-01 00:00:00.000Z]} + end) + + expect(Engine.LastTrip.Mock, :get_recent_departures, fn "2" -> + %{"b" => ~U[2023-01-01 00:00:00.000Z]} + end) + + expect(Engine.LastTrip.Mock, :is_last_trip?, fn "a" -> false end) + expect(Engine.LastTrip.Mock, :is_last_trip?, fn "b" -> true end) + + expect_messages( + {[{"Mattapan Stopped", 6}, {"Mattapan 8 stops", 6}, {"Mattapan away", 6}], + [{"Southbound No trains", 6}, {"Southbound Svc ended", 6}]} + ) + + expect_audios([ + {:canned, + {"115", + [ + "501", + "21000", + "507", + "21000", + "4100", + "21000", + "533", + "21000", + "641", + "21000", + "5008", + "21000", + "534" + ], :audio}}, + {:canned, {"105", ["787", "21000", "882"], :audio}} + ]) + + Signs.Realtime.handle_info(:run_loop, sign) + end + + test "No service goes on top line when bottom line needs more than 18 characters" do + sign = %{ + @jfk_mezzanine_sign + | tick_read: 0 + } + + expect(Engine.Predictions.Mock, :for_stop, fn "1", 0 -> + [] + end) + + expect(Engine.Predictions.Mock, :for_stop, fn "70086", 1 -> + [prediction(destination: :alewife, arrival: 240, stop_id: "70086")] + end) + + expect(Engine.Locations.Mock, :for_vehicle, fn _ -> nil end) + + expect(Engine.LastTrip.Mock, :get_recent_departures, fn "1" -> + %{"a" => ~U[2023-01-01 00:00:00.000Z]} + end) + + expect(Engine.LastTrip.Mock, :get_recent_departures, fn "70086" -> + %{"b" => ~U[2023-01-01 00:00:00.000Z]} + end) + + expect(Engine.LastTrip.Mock, :is_last_trip?, fn "a" -> true end) + expect(Engine.LastTrip.Mock, :is_last_trip?, fn "b" -> false end) + + expect_messages( + {"Southbound No Svc", [{"Alewife (A) 4 min", 6}, {"Alewife (Ashmont plat)", 6}]} + ) + + expect_audios([ + {:canned, {"105", ["787", "21000", "882"], :audio}}, + {:canned, {"98", ["4000", "503", "5004", "4016"], :audio}} + ]) + + Signs.Realtime.handle_info(:run_loop, sign) + end + end + defp expect_messages(messages) do expect(PaEss.Updater.Mock, :update_sign, fn {_, _}, top, bottom, 145, :now, _sign_id -> assert {top, bottom} == messages diff --git a/test/test_helper.exs b/test/test_helper.exs index 9edaa95e9..f0f77765e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -8,3 +8,4 @@ Mox.defmock(Engine.Alerts.Mock, for: Engine.AlertsAPI) Mox.defmock(Engine.Predictions.Mock, for: Engine.PredictionsAPI) Mox.defmock(Engine.ScheduledHeadways.Mock, for: Engine.ScheduledHeadwaysAPI) Mox.defmock(Engine.Locations.Mock, for: Engine.LocationsAPI) +Mox.defmock(Engine.LastTrip.Mock, for: Engine.LastTripAPI)