From 237b53c12034faccd2994afbb4a496ac14b048f6 Mon Sep 17 00:00:00 2001 From: Brett Heath-Wlaz Date: Thu, 17 Aug 2023 08:59:57 -0400 Subject: [PATCH] test prediction and headway logic at the sign level (#668) --- lib/engine/scheduled_headways.ex | 9 +- lib/engine/scheduled_headways_api.ex | 4 + test/signs/realtime_test.exs | 537 ++++++++++++++- test/signs/utilities/headways_test.exs | 190 ------ test/signs/utilities/predictions_test.exs | 759 ---------------------- test/test_helper.exs | 1 + 6 files changed, 526 insertions(+), 974 deletions(-) create mode 100644 lib/engine/scheduled_headways_api.ex delete mode 100644 test/signs/utilities/headways_test.exs delete mode 100644 test/signs/utilities/predictions_test.exs diff --git a/lib/engine/scheduled_headways.ex b/lib/engine/scheduled_headways.ex index 0af49bb24..db03ab961 100644 --- a/lib/engine/scheduled_headways.ex +++ b/lib/engine/scheduled_headways.ex @@ -4,6 +4,7 @@ defmodule Engine.ScheduledHeadways do Initially we will quickly update any newly registered stop so that we have something to show, then over time we will update every stop once every hour to make sure we stay up to date. """ + @behaviour Engine.ScheduledHeadwaysAPI use GenServer require Logger require Signs.Utilities.SignsConfig @@ -34,7 +35,7 @@ defmodule Engine.ScheduledHeadways do ) end - @spec init(Keyword.t()) :: {:ok, state()} + @impl true def init(opts) do headways_ets_table = opts[:headways_ets_table] || :scheduled_headways @@ -95,7 +96,7 @@ defmodule Engine.ScheduledHeadways do :ets.select(table_name, pattern) end - @spec get_first_scheduled_departure([binary]) :: nil | DateTime.t() + @impl true def get_first_scheduled_departure(stop_ids) do get_first_last_departures(stop_ids) |> Enum.map(&elem(&1, 0)) @@ -105,7 +106,7 @@ defmodule Engine.ScheduledHeadways do @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." - @spec display_headways?(:ets.tab(), [String.t()], DateTime.t(), non_neg_integer()) :: boolean() + @impl true def display_headways?( table \\ :scheduled_headways_first_last_departures, stop_ids, @@ -146,7 +147,7 @@ defmodule Engine.ScheduledHeadways do |> Enum.min_by(&DateTime.to_unix/1, fn -> nil end) end - @spec handle_info(:data_update, state) :: {:noreply, state} + @impl true def handle_info(:data_update, state) do schedule_data_update(self(), state.fetch_ms) diff --git a/lib/engine/scheduled_headways_api.ex b/lib/engine/scheduled_headways_api.ex new file mode 100644 index 000000000..56b91c3d3 --- /dev/null +++ b/lib/engine/scheduled_headways_api.ex @@ -0,0 +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/test/signs/realtime_test.exs b/test/signs/realtime_test.exs index 452d58d14..326321ebb 100644 --- a/test/signs/realtime_test.exs +++ b/test/signs/realtime_test.exs @@ -6,12 +6,6 @@ defmodule Signs.RealtimeTest do alias Content.Message.Headways.Top, as: HT alias Content.Message.Headways.Bottom, as: HB - defmodule FakeHeadways do - def get_headways(_stop_id), do: {1, 5} - def display_headways?(_stop_ids, _time, _buffer), do: true - def get_first_scheduled_departure(_stop_ids), do: nil - end - @headway_config %Engine.Config.Headway{headway_id: "id", range_low: 11, range_high: 13} @src %Signs.Utilities.SourceConfig{ @@ -38,7 +32,7 @@ defmodule Signs.RealtimeTest do current_content_top: %HT{destination: :southbound, vehicle_type: :train}, current_content_bottom: %HB{range: {11, 13}}, prediction_engine: Engine.Predictions.Mock, - headway_engine: FakeHeadways, + headway_engine: Engine.ScheduledHeadways.Mock, last_departure_engine: nil, config_engine: Engine.Config.Mock, alerts_engine: Engine.Alerts.Mock, @@ -50,6 +44,16 @@ defmodule Signs.RealtimeTest do read_period_seconds: 100 } + @mezzanine_sign %{ + @sign + | source_config: { + %{sources: [@src], headway_group: "group", headway_destination: :northbound}, + %{sources: [@src], headway_group: "group", headway_destination: :southbound} + }, + current_content_top: %HT{vehicle_type: :train, routes: []}, + current_content_bottom: %HB{range: {11, 13}} + } + @predictions [ %Predictions.Prediction{ stop_id: "1", @@ -70,6 +74,87 @@ defmodule Signs.RealtimeTest do destination_stop_id: "70093", seconds_until_arrival: 240, seconds_until_departure: 300 + }, + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Red", + stopped?: false, + stops_away: 1, + destination_stop_id: "70093", + seconds_until_arrival: 360, + seconds_until_departure: 420 + } + ] + + @multiple_arriving_prediction1 %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Green-C", + stopped?: false, + stops_away: 1, + destination_stop_id: "123", + seconds_until_arrival: 15, + seconds_until_departure: 50 + } + + @multiple_arriving_prediction2 %Predictions.Prediction{ + stop_id: "2", + direction_id: 0, + route_id: "Green-D", + stopped?: false, + stops_away: 1, + destination_stop_id: "123", + seconds_until_arrival: 16, + seconds_until_departure: 50 + } + + @passthrough_prediction1 %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Red", + stopped?: false, + stops_away: 4, + destination_stop_id: "70105", + seconds_until_arrival: nil, + seconds_until_departure: nil, + seconds_until_passthrough: 30, + trip_id: "123" + } + + @passthrough_prediction2 %Predictions.Prediction{ + stop_id: "1", + direction_id: 1, + route_id: "Red", + stopped?: false, + stops_away: 4, + destination_stop_id: "70105", + seconds_until_arrival: nil, + seconds_until_departure: nil, + seconds_until_passthrough: 30, + trip_id: "124" + } + + @later_boarding_predictions [ + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Green-B", + stopped?: false, + stops_away: 1, + destination_stop_id: "123", + seconds_until_arrival: 200, + seconds_until_departure: 250 + }, + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Green-C", + stopped?: false, + stops_away: 0, + destination_stop_id: "123", + seconds_until_arrival: 250, + seconds_until_departure: 300 } ] @@ -104,6 +189,8 @@ defmodule Signs.RealtimeTest do stub(Engine.Config.Mock, :headway_config, fn _, _ -> @headway_config end) 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 @@ -133,20 +220,7 @@ defmodule Signs.RealtimeTest do test "announces train passing through station" do expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> - [ - %Predictions.Prediction{ - stop_id: "passthrough_audio", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 4, - destination_stop_id: "70105", - seconds_until_arrival: nil, - seconds_until_departure: nil, - seconds_until_passthrough: 30, - trip_id: "123" - } - ] + [@passthrough_prediction1, @passthrough_prediction2] end) expect_audios([{:canned, {"103", ["32118"], :audio_visual}}]) @@ -154,6 +228,24 @@ defmodule Signs.RealtimeTest do assert sign.announced_passthroughs == ["123"] end + test "announces passthrough trains for mezzanine signs" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> [@passthrough_prediction1] end) + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> [@passthrough_prediction2] end) + expect_audios([{:canned, {"103", ["32118"], :audio_visual}}]) + expect_audios([{:canned, {"103", ["32114"], :audio_visual}}]) + + Signs.Realtime.handle_info(:run_loop, @mezzanine_sign) + end + + test "announces passthrough audio for 'Southbound' headsign" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [%{@passthrough_prediction1 | destination_stop_id: "70083"}] + end) + + expect_audios([{:canned, {"103", ["32117"], :audio_visual}}]) + Signs.Realtime.handle_info(:run_loop, @sign) + end + test "when custom text is present, display it, overriding alerts" do expect(Engine.Config.Mock, :sign_config, fn _ -> {:static_text, {"custom", "message"}} end) expect(Engine.Alerts.Mock, :max_stop_status, fn _, _ -> :suspension_closed_station end) @@ -275,6 +367,409 @@ defmodule Signs.RealtimeTest do expect_audios([@no_service_audio]) Signs.Realtime.handle_info(:run_loop, @sign) end + + test "generates empty messages if no headway is configured for some reason" do + expect(Engine.Config.Mock, :headway_config, fn _, _ -> nil end) + expect_messages({"", ""}) + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "generates empty messages if outside of service hours" do + expect(Engine.ScheduledHeadways.Mock, :display_headways?, fn _, _, _ -> false end) + expect_messages({"", ""}) + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "generates non-directional headway message at center/mezz signs" do + expect(Engine.Config.Mock, :headway_config, fn _, _ -> + %{@headway_config | range_high: 14} + end) + + expect_messages({"Trains", "Every 11 to 14 min"}) + Signs.Realtime.handle_info(:run_loop, @mezzanine_sign) + end + + test "when given two source lists, returns earliest result from each" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> @predictions end) + + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [ + %Predictions.Prediction{ + stop_id: "2", + direction_id: 1, + route_id: "Red", + stopped?: false, + stops_away: 1, + destination_stop_id: "123", + seconds_until_arrival: 10, + seconds_until_departure: 20 + }, + %Predictions.Prediction{ + stop_id: "2", + direction_id: 1, + route_id: "Red", + stopped?: false, + stops_away: 1, + destination_stop_id: "123", + seconds_until_arrival: 70, + seconds_until_departure: 80 + } + ] + end) + + expect_messages({"Ashmont 2 min", "Alewife ARR"}) + Signs.Realtime.handle_info(:run_loop, @mezzanine_sign) + end + + test "sorts by arrival or departure depending on which is present" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [ + %Predictions.Prediction{ + stop_id: "1", + direction_id: 1, + route_id: "Red", + stopped?: false, + stops_away: 1, + destination_stop_id: "123", + seconds_until_arrival: 240, + seconds_until_departure: 300 + }, + %Predictions.Prediction{ + stop_id: "1", + direction_id: 1, + route_id: "Red", + stopped?: false, + stops_away: 1, + destination_stop_id: "123", + seconds_until_arrival: nil, + seconds_until_departure: 480 + } + ] + end) + + expect_messages({"Alewife 4 min", "Alewife 8 min"}) + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "When the train is stopped a long time away, but not quite max time, shows stopped" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [ + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Mattapan", + stopped?: false, + stops_away: 8, + boarding_status: "Stopped 8 stop away", + destination_stop_id: "123", + seconds_until_arrival: 1100, + seconds_until_departure: 10 + } + ] + end) + + expect_messages( + {[{"Mattapan Stopped", 6}, {"Mattapan 8 stops", 6}, {"Mattapan away", 6}], ""} + ) + + expect_audios([ + {:canned, + {"115", + [ + "501", + "21000", + "507", + "21000", + "4100", + "21000", + "533", + "21000", + "641", + "21000", + "5008", + "21000", + "534" + ], :audio}} + ]) + + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "When the train is stopped a long time away from a terminal, shows max time instead of stopped" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [ + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Mattapan", + stopped?: false, + stops_away: 8, + boarding_status: "Stopped 8 stop away", + destination_stop_id: "123", + seconds_until_arrival: 10, + seconds_until_departure: 2020 + } + ] + end) + + expect_messages({"Mattapan 20+ min", ""}) + expect_audios([{:canned, {"90", ["4100", "502", "5020"], :audio}}]) + + Signs.Realtime.handle_info(:run_loop, %{ + @sign + | source_config: %{@sign.source_config | sources: [%{@src | terminal?: true}]} + }) + end + + test "When the train is stopped a long time away, shows max time instead of stopped" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [ + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Mattapan", + stopped?: false, + stops_away: 8, + boarding_status: "Stopped 8 stop away", + destination_stop_id: "123", + seconds_until_arrival: 1200, + seconds_until_departure: 10 + } + ] + end) + + expect_messages({"Mattapan 20+ min", ""}) + expect_audios([{:canned, {"90", ["4100", "503", "5020"], :audio}}]) + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "only the first prediction in a source list can be BRD" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [ + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Mattapan", + stopped?: false, + stops_away: 0, + destination_stop_id: "123", + seconds_until_arrival: 0, + seconds_until_departure: 90, + boarding_status: nil + }, + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Mattapan", + stopped?: false, + stops_away: 1, + destination_stop_id: "123", + seconds_until_arrival: 100, + seconds_until_departure: 120 + } + ] + end) + + expect_messages({"Mattapan BRD", "Mattapan 2 min"}) + + expect_audios([ + {:canned, {"109", ["501", "21000", "507", "21000", "4100", "21000", "544"], :audio}}, + {:canned, {"160", ["4100", "503", "5002"], :audio}} + ]) + + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "Sorts boarding status to the top" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> @later_boarding_predictions end) + + expect_messages({"Clvlnd Cir BRD", "Boston Col 3 min"}) + + expect_audios([ + {:canned, + {"111", ["501", "21000", "537", "21000", "507", "21000", "4203", "21000", "544"], :audio}}, + {:canned, + {"117", + [ + "501", + "21000", + "536", + "21000", + "507", + "21000", + "4202", + "21000", + "503", + "21000", + "504", + "21000", + "5003", + "21000", + "505" + ], :audio}} + ]) + + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "does not allow ARR on second line if platform does not have multiple berths" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [@multiple_arriving_prediction1, @multiple_arriving_prediction2] + end) + + expect_messages({"Clvlnd Cir ARR", "Riverside 1 min"}) + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "allows ARR on second line if platform does have multiple berths" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> [@multiple_arriving_prediction1] end) + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> [@multiple_arriving_prediction2] end) + + expect_messages({"Clvlnd Cir ARR", "Riverside ARR"}) + + Signs.Realtime.handle_info(:run_loop, %{ + @sign + | source_config: %{ + @sign.source_config + | sources: [ + %{@src | stop_id: "1", multi_berth?: true}, + %{@src | stop_id: "2", multi_berth?: true} + ] + } + }) + end + + test "doesn't sort 0 stops away to first for terminals when another departure is sooner" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> @later_boarding_predictions end) + + expect_messages({"Boston Col 3 min", "Clvlnd Cir 4 min"}) + + Signs.Realtime.handle_info(:run_loop, %{ + @sign + | source_config: %{@sign.source_config | sources: [%{@src | terminal?: true}]} + }) + end + + test "properly handles case where destination can't be determined" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [ + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Not a Valid Route", + stopped?: false, + stops_away: 0, + destination_stop_id: "Not a Valid Stop ID", + seconds_until_arrival: nil, + seconds_until_departure: 240, + trip_id: "123" + } + ] + end) + + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "Correctly orders BRD predictions between trains mid-trip and those starting their trip" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [ + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Green-D", + stopped?: false, + stops_away: 0, + destination_stop_id: "123", + seconds_until_arrival: -30, + seconds_until_departure: 60 + }, + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Green-D", + stopped?: false, + stops_away: 0, + destination_stop_id: "123", + seconds_until_arrival: -15, + seconds_until_departure: 75 + }, + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Green-B", + stopped?: false, + stops_away: 0, + destination_stop_id: "123", + seconds_until_arrival: nil, + seconds_until_departure: 60 + } + ] + end) + + expect_messages({"Riverside BRD", "Boston Col BRD"}) + + expect_audios([ + {:canned, + {"111", ["501", "21000", "538", "21000", "507", "21000", "4084", "21000", "544"], :audio}}, + {:canned, + {"111", ["501", "21000", "536", "21000", "507", "21000", "4202", "21000", "544"], :audio}} + ]) + + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "prefers showing distinct destinations when present" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [ + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Red", + stopped?: false, + stops_away: 1, + destination_stop_id: "70085", + seconds_until_arrival: 120, + seconds_until_departure: 180 + }, + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Red", + stopped?: false, + stops_away: 1, + destination_stop_id: "70085", + seconds_until_arrival: 500, + seconds_until_departure: 600 + }, + %Predictions.Prediction{ + stop_id: "1", + direction_id: 0, + route_id: "Red", + stopped?: false, + stops_away: 1, + destination_stop_id: "70099", + seconds_until_arrival: 700, + seconds_until_departure: 800 + } + ] + end) + + expect_messages({"Ashmont 2 min", "Braintree 12 min"}) + Signs.Realtime.handle_info(:run_loop, @sign) + end + + test "handles passthrough audio where headsign can't be determined" do + expect(Engine.Predictions.Mock, :for_stop, fn _, _ -> + [%{@passthrough_prediction1 | route_id: "Foo", destination_stop_id: "Bar"}] + end) + + log = + capture_log([level: :info], fn -> + Signs.Realtime.handle_info(:run_loop, @sign) + end) + + assert log =~ "no_passthrough_audio_for_prediction" + end end describe "decrement_ticks/1" do diff --git a/test/signs/utilities/headways_test.exs b/test/signs/utilities/headways_test.exs deleted file mode 100644 index 686dc6771..000000000 --- a/test/signs/utilities/headways_test.exs +++ /dev/null @@ -1,190 +0,0 @@ -defmodule Signs.Utilities.HeadwaysTest do - use ExUnit.Case - - defmodule FakeAlerts do - def max_stop_status(_stops, _routes), do: :none - end - - defmodule FakeDepartures do - @test_departure_time Timex.to_datetime(~N[2019-08-29 15:41:31], "America/New_York") - - def get_last_departure(_) do - @test_departure_time - end - - def test_departure_time() do - @test_departure_time - end - end - - defmodule FakeHeadways do - def display_headways?(["no_service"], _time, _buffer), do: false - def display_headways?(_, _time, _buffer), do: true - end - - defmodule FakeHeadwayConfigEngine do - def headway_config("first_dep_test", _time) do - %Engine.Config.Headway{headway_id: "id", range_low: 7, range_high: 11} - end - - def headway_config("config_test", _time) do - %Engine.Config.Headway{headway_id: "id", range_low: 9, range_high: 13} - end - - def headway_config("8-11", _time) do - %Engine.Config.Headway{headway_id: "id", range_low: 8, range_high: 11} - end - - def headway_config(_headway_group, _time) do - nil - end - end - - @sign %Signs.Realtime{ - id: "sign_id", - text_id: {"TEST", "x"}, - audio_id: {"TEST", ["x"]}, - source_config: {%{}, %{}}, - current_content_top: Content.Message.Empty.new(), - current_content_bottom: Content.Message.Empty.new(), - prediction_engine: FakePredictions, - headway_engine: FakeHeadways, - last_departure_engine: FakeDepartures, - config_engine: Engine.Config, - alerts_engine: FakeAlerts, - current_time_fn: nil, - sign_updater: FakeUpdater, - last_update: Timex.now(), - tick_audit: 240, - tick_read: 240, - read_period_seconds: 240 - } - - @spec source_config_for_stop_id(String.t()) :: %Signs.Utilities.SourceConfig{} - defp source_config_for_stop_id(stop_id) do - %Signs.Utilities.SourceConfig{ - stop_id: stop_id, - routes: ["Red"], - direction_id: 0, - platform: nil, - terminal?: false, - announce_arriving?: false, - announce_boarding?: false, - multi_berth?: false - } - end - - @current_time DateTime.utc_now() - - describe "get_messages/2" do - test "generates blank messages when the source config has multiple sources and the sign has no headway_stop_id" do - sign = %{ - @sign - | source_config: - {%{headway_group: "headway_group", sources: []}, - %{headway_group: "headway_group", sources: []}} - } - - assert Signs.Utilities.Headways.get_messages(sign, @current_time) == - {%Content.Message.Empty{}, %Content.Message.Empty{}} - end - - test "displays the headway at a single-source stop" do - sign = %{ - @sign - | source_config: %{ - headway_group: "8-11", - headway_destination: :southbound, - sources: [source_config_for_stop_id("a")] - }, - config_engine: FakeHeadwayConfigEngine - } - - assert Signs.Utilities.Headways.get_messages(sign, @current_time) == - { - %Content.Message.Headways.Top{destination: :southbound, vehicle_type: :train}, - %Content.Message.Headways.Bottom{ - range: {8, 11}, - prev_departure_mins: nil - } - } - end - - test "displays the headway at multi-source stop" do - sign = %{ - @sign - | source_config: %{ - headway_group: "8-11", - headway_destination: :southbound, - sources: [ - source_config_for_stop_id("f"), - source_config_for_stop_id("a"), - source_config_for_stop_id("c") - ] - }, - config_engine: FakeHeadwayConfigEngine - } - - assert Signs.Utilities.Headways.get_messages(sign, @current_time) == - { - %Content.Message.Headways.Top{destination: :southbound, vehicle_type: :train}, - %Content.Message.Headways.Bottom{ - range: {8, 11}, - prev_departure_mins: nil - } - } - end - - test "generates empty messages if no headway is configured for some reason" do - config = source_config_for_stop_id("a") - - sign = %{ - @sign - | source_config: %{ - headway_group: "none_configured", - headway_destination: :mattapan, - sources: [config] - }, - config_engine: FakeHeadwayConfigEngine - } - - assert Signs.Utilities.Headways.get_messages(sign, @current_time) == - {%Content.Message.Empty{}, %Content.Message.Empty{}} - end - - test "generates empty messages if outside of service hours" do - config = source_config_for_stop_id("no_service") - - sign = %{ - @sign - | source_config: %{ - headway_group: "8-11", - headway_destination: :mattapan, - sources: [config] - }, - config_engine: FakeHeadwayConfigEngine - } - - assert Signs.Utilities.Headways.get_messages(sign, @current_time) == - {%Content.Message.Empty{}, %Content.Message.Empty{}} - end - - test "generates non-directional headway message at center/mezz signs" do - sign = %{ - @sign - | source_config: - {%{ - headway_group: "8-11", - headway_destination: :inbound, - sources: [source_config_for_stop_id("mezz")] - }, %{headway_group: "8-11", headway_destination: :outbound, sources: []}}, - config_engine: FakeHeadwayConfigEngine - } - - assert { - %Content.Message.Headways.Top{destination: nil}, - %Content.Message.Headways.Bottom{range: {8, 11}} - } = Signs.Utilities.Headways.get_messages(sign, @current_time) - end - end -end diff --git a/test/signs/utilities/predictions_test.exs b/test/signs/utilities/predictions_test.exs deleted file mode 100644 index 67594d460..000000000 --- a/test/signs/utilities/predictions_test.exs +++ /dev/null @@ -1,759 +0,0 @@ -defmodule Signs.Utilities.PredictionsTest do - use ExUnit.Case - import ExUnit.CaptureLog - alias Signs.Utilities.SourceConfig - - defmodule FakePredictions do - end - - defmodule FakeUpdater do - end - - @sign %Signs.Realtime{ - id: "sign_id", - text_id: {"TEST", "x"}, - audio_id: {"TEST", ["x"]}, - source_config: {%{sources: []}, %{sources: []}}, - current_content_top: Content.Message.Empty.new(), - current_content_bottom: Content.Message.Empty.new(), - prediction_engine: FakePredictions, - headway_engine: FakeHeadways, - last_departure_engine: FakeDepartures, - config_engine: Engine.Config, - alerts_engine: nil, - current_time_fn: nil, - sign_updater: FakeUpdater, - last_update: Timex.now(), - tick_audit: 240, - tick_read: 240, - read_period_seconds: 240 - } - - describe "get_messages" do - test "when given two source lists, returns earliest result from each" do - predictions1 = [ - %Predictions.Prediction{ - stop_id: "1", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "70093", - seconds_until_arrival: 120, - seconds_until_departure: 180 - }, - %Predictions.Prediction{ - stop_id: "1", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "70093", - seconds_until_arrival: 240, - seconds_until_departure: 300 - } - ] - - predictions2 = [ - %Predictions.Prediction{ - stop_id: "2", - direction_id: 1, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 120, - seconds_until_departure: 180 - }, - %Predictions.Prediction{ - stop_id: "2", - direction_id: 1, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 240, - seconds_until_departure: 300 - } - ] - - assert { - %Content.Message.Predictions{destination: :ashmont, minutes: 2}, - %Content.Message.Predictions{destination: :alewife, minutes: 2} - } = Signs.Utilities.Predictions.get_messages({predictions1, predictions2}, @sign) - end - - test "when given one source list, returns earliest two results" do - predictions = [ - %Predictions.Prediction{ - stop_id: "3", - direction_id: 1, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 120, - seconds_until_departure: 180 - }, - %Predictions.Prediction{ - stop_id: "3", - direction_id: 1, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 500, - seconds_until_departure: 600 - }, - %Predictions.Prediction{ - stop_id: "4", - direction_id: 1, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 240, - seconds_until_departure: 300 - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.Predictions{destination: :alewife, minutes: 2}, - %Content.Message.Predictions{destination: :alewife, minutes: 4} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "sorts by arrival or departure depending on which is present" do - predictions = [ - %Predictions.Prediction{ - stop_id: "arrival_vs_departure_time", - direction_id: 1, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 240, - seconds_until_departure: 300 - }, - %Predictions.Prediction{ - stop_id: "arrival_vs_departure_time", - direction_id: 1, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: nil, - seconds_until_departure: 480 - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.Predictions{destination: :alewife, minutes: 4}, - %Content.Message.Predictions{destination: :alewife, minutes: 8} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "When the train is stopped a long time away, but not quite max time, shows stopped" do - predictions = [ - %Predictions.Prediction{ - stop_id: "stopped_a_long_time_away", - direction_id: 0, - route_id: "Mattapan", - stopped?: false, - stops_away: 8, - boarding_status: "Stopped 8 stop away", - destination_stop_id: "123", - seconds_until_arrival: 1100, - seconds_until_departure: 10 - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.StoppedTrain{stops_away: 8}, - %Content.Message.Empty{} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "When the train is stopped a long time away from a terminal, shows max time instead of stopped" do - predictions = [ - %Predictions.Prediction{ - stop_id: "stopped_a_long_time_away_terminal", - direction_id: 0, - route_id: "Mattapan", - stopped?: false, - stops_away: 8, - boarding_status: "Stopped 8 stop away", - destination_stop_id: "123", - seconds_until_arrival: 10, - seconds_until_departure: 2020 - } - ] - - src = %SourceConfig{ - stop_id: "stopped_a_long_time_away_terminal", - direction_id: 0, - terminal?: true, - platform: nil, - announce_arriving?: false, - announce_boarding?: false - } - - config = %{sources: [src]} - sign = %{@sign | source_config: config} - - assert { - %Content.Message.Predictions{destination: :mattapan, minutes: :max_time}, - %Content.Message.Empty{} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "When the train is stopped a long time away, shows max time instead of stopped" do - predictions = [ - %Predictions.Prediction{ - stop_id: "stopped_a_long_time_away", - direction_id: 0, - route_id: "Mattapan", - stopped?: false, - stops_away: 8, - boarding_status: "Stopped 8 stop away", - destination_stop_id: "123", - seconds_until_arrival: 1200, - seconds_until_departure: 10 - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.Predictions{destination: :mattapan, minutes: :max_time}, - %Content.Message.Empty{} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "pads out results if only one prediction" do - predictions = [ - %Predictions.Prediction{ - stop_id: "7", - direction_id: 1, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 0, - seconds_until_departure: 300 - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.Predictions{}, - %Content.Message.Empty{} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "pads out results if no predictions" do - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.Empty{}, - %Content.Message.Empty{} - } = Signs.Utilities.Predictions.get_messages([], sign) - end - - test "only the first prediction in a source list can be BRD" do - predictions = [ - %Predictions.Prediction{ - stop_id: "8", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 0, - destination_stop_id: "123", - seconds_until_arrival: 0, - seconds_until_departure: 90, - boarding_status: nil - }, - %Predictions.Prediction{ - stop_id: "8", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 100, - seconds_until_departure: 120 - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.Predictions{minutes: :boarding}, - %Content.Message.Predictions{minutes: 2} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "Returns stopped train message" do - predictions = [ - %Predictions.Prediction{ - stop_id: "9", - direction_id: 0, - route_id: "Red", - stopped?: true, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 10, - seconds_until_departure: 100, - boarding_status: "Stopped 1 stop away" - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.StoppedTrain{stops_away: 1}, - %Content.Message.Empty{} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "Only includes predictions if a departure prediction is present" do - predictions = [ - %Predictions.Prediction{ - stop_id: "stop_with_nil_departure_prediction", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 10, - seconds_until_departure: nil - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.Empty{}, - %Content.Message.Empty{} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "Sorts boarding status to the top" do - # when both are 0 stops away, sorts by time - predictions1 = [ - %Predictions.Prediction{ - stop_id: "both_brd", - direction_id: 0, - route_id: "Green-B", - stopped?: false, - stops_away: 0, - destination_stop_id: "123", - seconds_until_arrival: 200, - seconds_until_departure: 250 - }, - %Predictions.Prediction{ - stop_id: "both_brd", - direction_id: 0, - route_id: "Green-C", - stopped?: false, - stops_away: 0, - destination_stop_id: "123", - seconds_until_arrival: 250, - seconds_until_departure: 300 - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.Predictions{destination: :boston_college, minutes: :boarding}, - %Content.Message.Predictions{destination: :cleveland_circle, minutes: :boarding} - } = Signs.Utilities.Predictions.get_messages(predictions1, sign) - - # when second is 0 stops away, sorts first, even if "later" - predictions2 = [ - %Predictions.Prediction{ - stop_id: "second_brd", - direction_id: 0, - route_id: "Green-B", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 200, - seconds_until_departure: 250 - }, - %Predictions.Prediction{ - stop_id: "second_brd", - direction_id: 0, - route_id: "Green-C", - stopped?: false, - stops_away: 0, - destination_stop_id: "123", - seconds_until_arrival: 250, - seconds_until_departure: 300 - } - ] - - assert { - %Content.Message.Predictions{destination: :cleveland_circle, minutes: :boarding}, - %Content.Message.Predictions{destination: :boston_college, minutes: 3} - } = Signs.Utilities.Predictions.get_messages(predictions2, sign) - - # when first is 0 stops away, sorts first, even if "later" - predictions3 = [ - %Predictions.Prediction{ - stop_id: "first_brd", - direction_id: 0, - route_id: "Green-B", - stopped?: false, - stops_away: 0, - destination_stop_id: "123", - seconds_until_arrival: 250, - seconds_until_departure: 300 - }, - %Predictions.Prediction{ - stop_id: "first_brd", - direction_id: 0, - route_id: "Green-C", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 200, - seconds_until_departure: 250 - } - ] - - assert { - %Content.Message.Predictions{destination: :boston_college, minutes: :boarding}, - %Content.Message.Predictions{destination: :cleveland_circle, minutes: 3} - } = Signs.Utilities.Predictions.get_messages(predictions3, sign) - end - - test "Does not allow ARR on second line unless platform has multiple berths" do - predictions = [ - %Predictions.Prediction{ - stop_id: "arr_multi_berth1", - direction_id: 0, - route_id: "Green-C", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 15, - seconds_until_departure: 50 - }, - %Predictions.Prediction{ - stop_id: "arr_multi_berth2", - direction_id: 0, - route_id: "Green-D", - stopped?: false, - stops_away: 1, - destination_stop_id: "123", - seconds_until_arrival: 16, - seconds_until_departure: 50 - } - ] - - s1 = %SourceConfig{ - stop_id: "arr_multi_berth1", - direction_id: 0, - terminal?: false, - platform: nil, - routes: nil, - announce_arriving?: false, - announce_boarding?: false, - multi_berth?: true - } - - s2 = %{s1 | stop_id: "arr_multi_berth2"} - - config = %{sources: [s1, s2]} - sign = %{@sign | source_config: config} - - assert { - %Content.Message.Predictions{destination: :cleveland_circle, minutes: :arriving}, - %Content.Message.Predictions{destination: :riverside, minutes: :arriving} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - - s1 = %{s1 | multi_berth?: false} - s2 = %{s2 | multi_berth?: false} - config = %{sources: [s1, s2]} - sign = %{@sign | source_config: config} - - assert { - %Content.Message.Predictions{destination: :cleveland_circle, minutes: :arriving}, - %Content.Message.Predictions{destination: :riverside, minutes: 1} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "Correctly orders BRD predictions between trains mid-trip and those starting their trip" do - predictions = [ - %Predictions.Prediction{ - stop_id: "multiple_brd_some_first_stop_1", - direction_id: 0, - route_id: "Green-D", - stopped?: false, - stops_away: 0, - destination_stop_id: "123", - seconds_until_arrival: -30, - seconds_until_departure: 60 - }, - %Predictions.Prediction{ - stop_id: "multiple_brd_some_first_stop_1", - direction_id: 0, - route_id: "Green-D", - stopped?: false, - stops_away: 0, - destination_stop_id: "123", - seconds_until_arrival: -15, - seconds_until_departure: 75 - }, - %Predictions.Prediction{ - stop_id: "multiple_brd_some_first_stop_2", - direction_id: 0, - route_id: "Green-B", - stopped?: false, - stops_away: 0, - destination_stop_id: "123", - seconds_until_arrival: nil, - seconds_until_departure: 60 - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.Predictions{destination: :riverside, minutes: :boarding}, - %Content.Message.Predictions{destination: :boston_college, minutes: :boarding} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "doesn't sort 0 stops away to first for terminals when another departure is sooner" do - predictions = [ - %Predictions.Prediction{ - stop_id: "terminal_dont_sort_0_stops_first", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "70105", - seconds_until_arrival: nil, - seconds_until_departure: 120, - trip_id: "123" - }, - %Predictions.Prediction{ - stop_id: "terminal_dont_sort_0_stops_first", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 0, - destination_stop_id: "70093", - seconds_until_arrival: nil, - seconds_until_departure: 240, - trip_id: "123" - } - ] - - s = %SourceConfig{ - stop_id: "terminal_dont_sort_0_stops_first", - direction_id: 0, - terminal?: true, - platform: nil, - routes: nil, - announce_arriving?: false, - announce_boarding?: true - } - - config = %{sources: [s]} - sign = %{@sign | source_config: config} - - assert { - %Content.Message.Predictions{destination: :braintree, minutes: 1}, - %Content.Message.Predictions{destination: :ashmont, minutes: 3} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - - test "properly handles case where destination can't be determined" do - predictions = [ - %Predictions.Prediction{ - stop_id: "indeterminate_destination", - direction_id: 0, - route_id: "Not a Valid Route", - stopped?: false, - stops_away: 0, - destination_stop_id: "Not a Valid Stop ID", - seconds_until_arrival: nil, - seconds_until_departure: 240, - trip_id: "123" - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert Signs.Utilities.Predictions.get_messages(predictions, sign) == - {%Content.Message.Empty{}, %Content.Message.Empty{}} - end - end - - describe "get_passthrough_train_audio/1" do - test "returns appropriate audio structs for multi-source sign" do - predictions = [ - %Predictions.Prediction{ - stop_id: "passthrough_trains", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 4, - destination_stop_id: "70105", - seconds_until_arrival: nil, - seconds_until_departure: nil, - seconds_until_passthrough: 30, - trip_id: "123" - } - ] - - assert Signs.Utilities.Predictions.get_passthrough_train_audio({predictions, []}) == [ - %Content.Audio.Passthrough{ - destination: :braintree, - route_id: "Red", - trip_id: "123" - } - ] - end - - test "returns appropriate audio structs for single-source sign" do - predictions = [ - %Predictions.Prediction{ - stop_id: "passthrough_trains", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 4, - destination_stop_id: "70105", - seconds_until_arrival: nil, - seconds_until_departure: nil, - seconds_until_passthrough: 30, - trip_id: "123" - }, - %Predictions.Prediction{ - stop_id: "passthrough_trains", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 4, - destination_stop_id: "70093", - seconds_until_arrival: nil, - seconds_until_departure: nil, - seconds_until_passthrough: 60, - trip_id: "123" - } - ] - - assert Signs.Utilities.Predictions.get_passthrough_train_audio(predictions) == [ - %Content.Audio.Passthrough{ - destination: :braintree, - trip_id: "123", - route_id: "Red" - } - ] - end - - test "handles \"Southbound\" headsign" do - predictions = [ - %Predictions.Prediction{ - stop_id: "passthrough_trains_southbound_red_line_destination", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 4, - destination_stop_id: "70083", - seconds_until_arrival: nil, - seconds_until_departure: nil, - seconds_until_passthrough: 30, - trip_id: "123" - } - ] - - assert Signs.Utilities.Predictions.get_passthrough_train_audio(predictions) == - [ - %Content.Audio.Passthrough{ - destination: :ashmont, - trip_id: "123", - route_id: "Red" - } - ] - end - - test "handles case where headsign can't be determined" do - predictions = [ - %Predictions.Prediction{ - stop_id: "passthrough_trains_bad_destination", - direction_id: 1, - route_id: "Foo", - stopped?: false, - stops_away: 4, - destination_stop_id: "bar", - seconds_until_arrival: nil, - seconds_until_departure: nil, - seconds_until_passthrough: 60, - trip_id: "123" - } - ] - - log = - capture_log([level: :info], fn -> - assert Signs.Utilities.Predictions.get_passthrough_train_audio(predictions) == [] - end) - - assert log =~ "no_passthrough_audio_for_prediction" - end - - test "prefers showing distinct destinations when present" do - predictions = [ - %Predictions.Prediction{ - stop_id: "multiple_destinations", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "70085", - seconds_until_arrival: 120, - seconds_until_departure: 180 - }, - %Predictions.Prediction{ - stop_id: "multiple_destinations", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "70085", - seconds_until_arrival: 500, - seconds_until_departure: 600 - }, - %Predictions.Prediction{ - stop_id: "multiple_destinations", - direction_id: 0, - route_id: "Red", - stopped?: false, - stops_away: 1, - destination_stop_id: "70099", - seconds_until_arrival: 700, - seconds_until_departure: 800 - } - ] - - sign = %{@sign | source_config: %{sources: []}} - - assert { - %Content.Message.Predictions{destination: :ashmont}, - %Content.Message.Predictions{destination: :braintree} - } = Signs.Utilities.Predictions.get_messages(predictions, sign) - end - end -end diff --git a/test/test_helper.exs b/test/test_helper.exs index d04a6fff7..7d0e73f79 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -6,3 +6,4 @@ Mox.defmock(PaEss.Updater.Mock, for: PaEss.Updater) Mox.defmock(Engine.Config.Mock, for: Engine.ConfigAPI) Mox.defmock(Engine.Alerts.Mock, for: Engine.AlertsAPI) Mox.defmock(Engine.Predictions.Mock, for: Engine.PredictionsAPI) +Mox.defmock(Engine.ScheduledHeadways.Mock, for: Engine.ScheduledHeadwaysAPI)