From c806bae02786803fb87425529980bf648074b6a4 Mon Sep 17 00:00:00 2001 From: Jon Zimbel <63608771+jzimbel-mbta@users.noreply.github.com> Date: Thu, 24 Aug 2023 09:41:45 -0400 Subject: [PATCH 1/2] fix: Tweak config so vscode can consistently resolve aliased import paths (#1835) --- assets/tsconfig.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/tsconfig.json b/assets/tsconfig.json index c7b5d30c7..23ed2fc1f 100644 --- a/assets/tsconfig.json +++ b/assets/tsconfig.json @@ -45,10 +45,10 @@ "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, "baseUrl": "./src", /* Base directory to resolve non-absolute module names. */ "paths": { - "Components": ["/components"], - "Hooks": ["/hooks"], - "Util": ["/util"], - "Constants": ["/constants"] + "Components/*": ["components/*"], + "Hooks/*": ["hooks/*"], + "Util/*": ["util/*"], + "Constants/*": ["constants/*"] }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ From cd4f3304b2e05bfc9639ec0670966c90ba3a295e Mon Sep 17 00:00:00 2001 From: Hannah Purcell <69368883+hannahpurcell@users.noreply.github.com> Date: Fri, 25 Aug 2023 15:46:58 -0400 Subject: [PATCH 2/2] [feature] Crowding widget backend (#1834) * Stubbed out template & ol crowding widget config * Adjust triptych config, added screen type to stop file and evergreen candidategenerator * Change parsing of vehicle info, add logic for generating crowding widget * Add "enabled" flag to widget; if no predictions, turn off crowding widget * If an alert makes a temp terminal, suppress widget * Update carriage serializer with known data structure * Two vehicle parsers: bus & subway. Added occupancy_status types * Re-order function defs, handle nil carriages for a vehicle * Shouldn't remove parent_stop_id from vehicles --- lib/screens/config/v2/ol_crowding.ex | 13 - lib/screens/config/v2/train_crowding.ex | 28 ++ lib/screens/config/v2/triptych.ex | 9 +- lib/screens/predictions/prediction.ex | 9 + lib/screens/stops/stop.ex | 18 +- .../v2/candidate_generator/triptych.ex | 15 +- .../candidate_generator/widgets/evergreen.ex | 4 +- .../widgets/train_crowding.ex | 91 ++++++ .../v2/widget_instance/train_crowding.ex | 90 ++++++ lib/screens/vehicles/parser.ex | 32 ++ lib/screens/vehicles/vehicle.ex | 16 +- .../v2/candidate_generator/triptych_test.exs | 41 ++- .../widgets/train_crowding_test.exs | 281 ++++++++++++++++++ .../widget_instance/train_crowding_test.exs | 113 +++++++ 14 files changed, 716 insertions(+), 44 deletions(-) delete mode 100644 lib/screens/config/v2/ol_crowding.ex create mode 100644 lib/screens/config/v2/train_crowding.ex create mode 100644 lib/screens/v2/candidate_generator/widgets/train_crowding.ex create mode 100644 lib/screens/v2/widget_instance/train_crowding.ex create mode 100644 test/screens/v2/candidate_generator/widgets/train_crowding_test.exs create mode 100644 test/screens/v2/widget_instance/train_crowding_test.exs diff --git a/lib/screens/config/v2/ol_crowding.ex b/lib/screens/config/v2/ol_crowding.ex deleted file mode 100644 index ca8d5ca44..000000000 --- a/lib/screens/config/v2/ol_crowding.ex +++ /dev/null @@ -1,13 +0,0 @@ -defmodule Screens.Config.V2.OLCrowding do - @moduledoc false - alias Screens.Config.V2.Header.CurrentStopName - - @type t :: %__MODULE__{ - station: CurrentStopName.t() - } - - @enforce_keys [:station] - defstruct station: nil - - use Screens.Config.Struct, children: [station: CurrentStopName] -end diff --git a/lib/screens/config/v2/train_crowding.ex b/lib/screens/config/v2/train_crowding.ex new file mode 100644 index 000000000..cfb22313a --- /dev/null +++ b/lib/screens/config/v2/train_crowding.ex @@ -0,0 +1,28 @@ +defmodule Screens.Config.V2.TrainCrowding do + @moduledoc false + + @type t :: %__MODULE__{ + station_id: String.t(), + route_id: String.t(), + direction_id: 0 | 1, + platform_position: pos_integer(), + front_car_direction: :left | :right, + enabled: boolean() + } + + @enforce_keys [:station_id, :direction_id, :platform_position, :front_car_direction] + defstruct station_id: nil, + route_id: "Orange", + direction_id: nil, + platform_position: nil, + front_car_direction: nil, + enabled: false + + use Screens.Config.Struct + + defp value_from_json("front_car_direction", "left"), do: :left + defp value_from_json("front_car_direction", "right"), do: :right + defp value_from_json(_, value), do: value + + defp value_to_json(_, value), do: value +end diff --git a/lib/screens/config/v2/triptych.ex b/lib/screens/config/v2/triptych.ex index 2ac2944d3..90a1c0715 100644 --- a/lib/screens/config/v2/triptych.ex +++ b/lib/screens/config/v2/triptych.ex @@ -1,16 +1,17 @@ defmodule Screens.Config.V2.Triptych do @moduledoc false - alias Screens.Config.V2.{EvergreenContentItem, OLCrowding} + alias Screens.Config.V2.{EvergreenContentItem, TrainCrowding} @type t :: %__MODULE__{ - ol_crowding: OLCrowding.t(), + train_crowding: TrainCrowding.t(), evergreen_content: list(EvergreenContentItem.t()) } - defstruct ol_crowding: nil, + @enforce_keys [:train_crowding, :evergreen_content] + defstruct train_crowding: nil, evergreen_content: [] use Screens.Config.Struct, - children: [ol_crowding: OLCrowding, evergreen_content: {:list, EvergreenContentItem}] + children: [train_crowding: TrainCrowding, evergreen_content: {:list, EvergreenContentItem}] end diff --git a/lib/screens/predictions/prediction.ex b/lib/screens/predictions/prediction.ex index ab6c2a75a..fe857eaa2 100644 --- a/lib/screens/predictions/prediction.ex +++ b/lib/screens/predictions/prediction.ex @@ -2,6 +2,7 @@ defmodule Screens.Predictions.Prediction do @moduledoc false alias Screens.Departures.Departure + alias Screens.Vehicles.Vehicle defstruct id: nil, trip: nil, @@ -42,4 +43,12 @@ defmodule Screens.Predictions.Prediction do :error -> :error end end + + def stop_for_vehicle(%__MODULE__{vehicle: %Vehicle{stop_id: stop_id}}), do: stop_id + def stop_for_vehicle(_), do: nil + + def vehicle_status(%__MODULE__{vehicle: %Vehicle{current_status: current_status}}), + do: current_status + + def vehicle_status(_), do: nil end diff --git a/lib/screens/stops/stop.ex b/lib/screens/stops/stop.ex index 2feeeaf54..105d5a36c 100644 --- a/lib/screens/stops/stop.ex +++ b/lib/screens/stops/stop.ex @@ -10,7 +10,7 @@ defmodule Screens.Stops.Stop do require Logger - alias Screens.Config.V2.{BusEink, BusShelter, Dup, GlEink, PreFare} + alias Screens.Config.V2.{BusEink, BusShelter, Dup, GlEink, PreFare, Triptych} alias Screens.LocationContext alias Screens.RoutePatterns.RoutePattern alias Screens.Routes @@ -31,7 +31,7 @@ defmodule Screens.Stops.Stop do platform_code: String.t() | nil } - @type screen_type :: BusEink | BusShelter | GlEink | PreFare | Dup + @type screen_type :: BusEink | BusShelter | GlEink | PreFare | Dup | Triptych @blue_line_stops [ {"place-wondl", {"Wonderland", "Wonderland"}}, @@ -371,6 +371,17 @@ defmodule Screens.Stops.Stop do end end + def fetch_parent_stop_id(stop_id) do + case Screens.V3Api.get_json("stops/" <> stop_id, %{"include" => "parent_station"}) do + {:ok, %{"included" => [included_data]}} -> + %{"id" => parent_station_id} = included_data + parent_station_id + + _ -> + nil + end + end + # --- END API functions --- def stop_on_route?(stop_id, stop_sequence) when not is_nil(stop_id) do @@ -457,7 +468,7 @@ defmodule Screens.Stops.Stop do app in [BusEink, BusShelter, GlEink] -> RoutePattern.fetch_stop_sequences_through_stop(stop_id) - app in [PreFare, Dup] -> + app in [PreFare, Dup, Triptych] -> RoutePattern.fetch_parent_station_sequences_through_stop(stop_id, route_ids) end) do {:ok, @@ -487,6 +498,7 @@ defmodule Screens.Stops.Stop do # Ashmont should not show Mattapan alerts for PreFare or Dup def get_route_type_filter(app, "place-asmnl") when app in [PreFare, Dup], do: [:subway] def get_route_type_filter(PreFare, _), do: [:light_rail, :subway] + def get_route_type_filter(Triptych, _), do: [:light_rail, :subway] # WTC is a special bus-only case def get_route_type_filter(Dup, "place-wtcst"), do: [:bus] def get_route_type_filter(Dup, _), do: [:light_rail, :subway] diff --git a/lib/screens/v2/candidate_generator/triptych.ex b/lib/screens/v2/candidate_generator/triptych.ex index 4a593afb2..c432acdb0 100644 --- a/lib/screens/v2/candidate_generator/triptych.ex +++ b/lib/screens/v2/candidate_generator/triptych.ex @@ -2,6 +2,7 @@ defmodule Screens.V2.CandidateGenerator.Triptych do @moduledoc false alias Screens.V2.CandidateGenerator + alias Screens.V2.CandidateGenerator.Widgets alias Screens.V2.Template.Builder alias Screens.V2.WidgetInstance.Placeholder @@ -12,18 +13,21 @@ defmodule Screens.V2.CandidateGenerator.Triptych do def screen_template do {:screen, %{ - normal: [:header, :main_content], - takeover: [:full_screen] + screen_normal: [:full_screen], + screen_split: [:first_pane, :second_pane, :third_pane] }} |> Builder.build_template() end @impl CandidateGenerator def candidate_instances( - _config, - _now \\ DateTime.utc_now() + config, + crowding_widget_instances_fn \\ &Widgets.TrainCrowding.crowding_widget_instances/1, + evergreen_content_instances_fn \\ &Widgets.Evergreen.evergreen_content_instances/1 ) do [ + fn -> crowding_widget_instances_fn.(config) end, + fn -> evergreen_content_instances_fn.(config) end, fn -> placeholder_instances() end ] |> Task.async_stream(& &1.(), ordered: false, timeout: 20_000) @@ -35,7 +39,8 @@ defmodule Screens.V2.CandidateGenerator.Triptych do defp placeholder_instances do [ - %Placeholder{color: :blue, slot_names: [:main_content]} + %Placeholder{color: :blue, slot_names: [:full_screen]}, + %Placeholder{color: :green, slot_names: [:third_pane]} ] end end diff --git a/lib/screens/v2/candidate_generator/widgets/evergreen.ex b/lib/screens/v2/candidate_generator/widgets/evergreen.ex index c5c0f6a29..29038a49d 100644 --- a/lib/screens/v2/candidate_generator/widgets/evergreen.ex +++ b/lib/screens/v2/candidate_generator/widgets/evergreen.ex @@ -4,14 +4,14 @@ defmodule Screens.V2.CandidateGenerator.Widgets.Evergreen do alias Screens.Config.Screen alias Screens.Config.V2.EvergreenContentItem alias Screens.V2.WidgetInstance.EvergreenContent - alias Screens.Config.V2.{BusEink, BusShelter, Dup, GlEink, PreFare} + alias Screens.Config.V2.{BusEink, BusShelter, Dup, GlEink, PreFare, Triptych} alias Screens.Util.Assets def evergreen_content_instances( %Screen{app_params: %app{evergreen_content: evergreen_content}} = config, now \\ DateTime.utc_now() ) - when app in [BusEink, BusShelter, Dup, GlEink, PreFare] do + when app in [BusEink, BusShelter, Dup, GlEink, PreFare, Triptych] do Enum.map(evergreen_content, &evergreen_content_instance(&1, config, now)) end diff --git a/lib/screens/v2/candidate_generator/widgets/train_crowding.ex b/lib/screens/v2/candidate_generator/widgets/train_crowding.ex new file mode 100644 index 000000000..fa9398083 --- /dev/null +++ b/lib/screens/v2/candidate_generator/widgets/train_crowding.ex @@ -0,0 +1,91 @@ +defmodule Screens.V2.CandidateGenerator.Widgets.TrainCrowding do + @moduledoc false + + alias Screens.Alerts.Alert + alias Screens.Config.Screen + alias Screens.Config.V2.{TrainCrowding, Triptych} + alias Screens.Predictions.Prediction + alias Screens.Stops.Stop + alias Screens.V2.LocalizedAlert + alias Screens.V2.WidgetInstance.TrainCrowding, as: CrowdingWidget + + @spec crowding_widget_instances(Screen.t()) :: list(CrowdingWidget.t()) + def crowding_widget_instances( + config, + now \\ DateTime.utc_now(), + fetch_predictions_fn \\ &Prediction.fetch/1, + fetch_location_context_fn \\ &Stop.fetch_location_context/3, + fetch_parent_stop_id_fn \\ &Stop.fetch_parent_stop_id/1, + fetch_alerts_fn \\ &Alert.fetch/1 + ) + + def crowding_widget_instances( + %Screen{app_params: %Triptych{train_crowding: %TrainCrowding{enabled: false}}}, + _, + _, + _, + _, + _ + ) do + [] + end + + def crowding_widget_instances( + %Screen{app_params: %Triptych{train_crowding: train_crowding}} = config, + now, + fetch_predictions_fn, + fetch_location_context_fn, + fetch_parent_stop_id_fn, + fetch_alerts_fn + ) do + params = %{ + direction_id: train_crowding.direction_id, + route_ids: [train_crowding.route_id], + stop_ids: [train_crowding.station_id] + } + + with {:ok, predictions} <- fetch_predictions_fn.(params), + {:ok, location_context} <- + fetch_location_context_fn.(Triptych, train_crowding.station_id, now), + {:ok, alerts} <- + params |> Map.to_list() |> fetch_alerts_fn.() do + next_train_prediction = List.first(predictions) + + # If there is an upcoming train, it's headed to this station, and we're not at a temporary terminal, + # show the widget + if not is_nil(next_train_prediction) and + Prediction.vehicle_status(next_train_prediction) == :incoming_at and + next_train_prediction |> Prediction.stop_for_vehicle() |> fetch_parent_stop_id_fn.() == + train_crowding.station_id and + next_train_prediction.vehicle.carriages != [] and + not any_alert_makes_this_a_terminal?(alerts, location_context) do + [ + %CrowdingWidget{ + screen: config, + prediction: next_train_prediction, + now: now + } + ] + else + [] + end + else + :error -> [] + end + end + + # Given alerts at this station, check to see if any alert make this a temporary terminal + defp any_alert_makes_this_a_terminal?(alerts, location_context) do + Enum.any?(alerts, fn alert -> + temporary_terminal?(%{alert: alert, location_context: location_context}) + end) + end + + # credo:disable-for-next-line + # TODO: This isn't the first time we've written a temporary_terminal function, but this one + # is a little more reusable? Consider using this func in other places + defp temporary_terminal?(localized_alert) do + localized_alert.alert.effect in [:suspension, :shuttle] and + LocalizedAlert.location(localized_alert) in [:boundary_downstream, :boundary_upstream] + end +end diff --git a/lib/screens/v2/widget_instance/train_crowding.ex b/lib/screens/v2/widget_instance/train_crowding.ex new file mode 100644 index 000000000..1183ae81f --- /dev/null +++ b/lib/screens/v2/widget_instance/train_crowding.ex @@ -0,0 +1,90 @@ +defmodule Screens.V2.WidgetInstance.TrainCrowding do + @moduledoc """ + A widget that displays the crowding on a train that is en route to the current station. + """ + + alias Screens.Config.Screen + alias Screens.Config.V2.Triptych + alias Screens.Predictions.Prediction + + defstruct screen: nil, + prediction: nil, + now: nil + + @type t :: %__MODULE__{ + screen: Screen.t(), + prediction: Prediction.t(), + now: DateTime.t() + } + + @type widget_data :: %{ + destination: String.t(), + crowding: list(crowding_level), + platform_position: number, + front_car_direction: :left | :right, + now: String.t() + } + + @type crowding_level :: :no_data | :not_crowded | :some_crowding | :crowded | :disabled + + @spec serialize(t()) :: widget_data() + def serialize(%__MODULE__{ + screen: %Screen{app_params: %Triptych{train_crowding: train_crowding}}, + prediction: prediction, + now: now + }) do + %{ + destination: prediction.trip.headsign, + crowding: serialize_carriages(prediction.vehicle.carriages), + platform_position: train_crowding.platform_position, + front_car_direction: train_crowding.front_car_direction, + now: serialize_time(now) + } + end + + defp serialize_time(%DateTime{} = time) do + DateTime.to_iso8601(time) + end + + defp serialize_carriages(nil), do: nil + + defp serialize_carriages(carriages), + do: Enum.map(carriages, fn car -> serialize_occupancy_status(car) end) + + defp serialize_occupancy_status(:no_data_available), do: :no_data + defp serialize_occupancy_status(:many_seats_available), do: :not_crowded + defp serialize_occupancy_status(:few_seats_available), do: :not_crowded + defp serialize_occupancy_status(:standing_room_only), do: :some_crowding + defp serialize_occupancy_status(:crushed_standing_room_only), do: :crowded + defp serialize_occupancy_status(:full), do: :crowded + defp serialize_occupancy_status(:not_accepting_passengers), do: :disabled + + def priority(_instance), do: [1] + + def slot_names(_instance), do: [:full_screen] + + def widget_type(_instance), do: :train_crowding + + def valid_candidate?(_instance), do: true + + ### Required audio callbacks. The widget does not have audio equivalence, so these are "stubbed". + def audio_serialize(_t), do: %{} + def audio_sort_key(_t), do: [0] + def audio_valid_candidate?(_t), do: false + def audio_view(_t), do: nil + + defimpl Screens.V2.WidgetInstance do + alias Screens.V2.WidgetInstance.TrainCrowding + + def priority(instance), do: TrainCrowding.priority(instance) + def serialize(instance), do: TrainCrowding.serialize(instance) + def slot_names(instance), do: TrainCrowding.slot_names(instance) + def widget_type(instance), do: TrainCrowding.widget_type(instance) + def valid_candidate?(instance), do: TrainCrowding.valid_candidate?(instance) + + def audio_serialize(instance), do: TrainCrowding.audio_serialize(instance) + def audio_sort_key(instance), do: TrainCrowding.audio_sort_key(instance) + def audio_valid_candidate?(instance), do: TrainCrowding.audio_valid_candidate?(instance) + def audio_view(instance), do: TrainCrowding.audio_view(instance) + end +end diff --git a/lib/screens/vehicles/parser.ex b/lib/screens/vehicles/parser.ex index 1995173df..a9c25a901 100644 --- a/lib/screens/vehicles/parser.ex +++ b/lib/screens/vehicles/parser.ex @@ -10,6 +10,7 @@ defmodule Screens.Vehicles.Parser do def parse_vehicle(%{ "attributes" => %{ "direction_id" => direction_id, + "carriages" => carriages, "current_status" => current_status, "occupancy_status" => occupancy_status }, @@ -19,6 +20,7 @@ defmodule Screens.Vehicles.Parser do %Screens.Vehicles.Vehicle{ id: vehicle_id, direction_id: direction_id, + carriages: parse_carriages(carriages), current_status: parse_current_status(current_status), occupancy_status: parse_occupancy_status(occupancy_status), trip_id: trip_id_from_trip_data(trip_data), @@ -26,6 +28,32 @@ defmodule Screens.Vehicles.Parser do } end + def parse_vehicle(%{ + "attributes" => %{ + "direction_id" => direction_id, + "current_status" => current_status, + "occupancy_status" => occupancy_status + }, + "id" => vehicle_id, + "relationships" => %{"trip" => trip_data, "stop" => stop_data} + }) do + %Screens.Vehicles.Vehicle{ + id: vehicle_id, + direction_id: direction_id, + current_status: parse_current_status(current_status), + occupancy_status: parse_occupancy_status(occupancy_status), + trip_id: trip_id_from_trip_data(trip_data), + stop_id: stop_id_from_stop_data(stop_data) + } + end + + defp parse_carriages(data), do: Enum.map(data, &parse_car_crowding/1) + + defp parse_car_crowding(%{ + "occupancy_status" => occupancy_status + }), + do: parse_occupancy_status(occupancy_status) + defp trip_id_from_trip_data(%{"data" => %{"id" => trip_id}}), do: trip_id defp trip_id_from_trip_data(_), do: nil @@ -39,6 +67,10 @@ defmodule Screens.Vehicles.Parser do defp parse_occupancy_status("MANY_SEATS_AVAILABLE"), do: :many_seats_available defp parse_occupancy_status("FEW_SEATS_AVAILABLE"), do: :few_seats_available + defp parse_occupancy_status("STANDING_ROOM_ONLY"), do: :standing_room_only + defp parse_occupancy_status("CRUSHED_STANDING_ROOM_ONLY"), do: :crushed_standing_room_only defp parse_occupancy_status("FULL"), do: :full + defp parse_occupancy_status("NO_DATA_AVAILABLE"), do: :no_data_available + defp parse_occupancy_status("NOT_ACCEPTING_PASSENGERS"), do: :not_accepting_passengers defp parse_occupancy_status(_), do: nil end diff --git a/lib/screens/vehicles/vehicle.ex b/lib/screens/vehicles/vehicle.ex index b1892767f..6c64d43a4 100644 --- a/lib/screens/vehicles/vehicle.ex +++ b/lib/screens/vehicles/vehicle.ex @@ -7,10 +7,19 @@ defmodule Screens.Vehicles.Vehicle do trip_id: nil, stop_id: nil, parent_stop_id: nil, - occupancy_status: nil + occupancy_status: nil, + carriages: nil @type current_status :: :incoming_at | :stopped_at | :in_transit_to | nil - @type occupancy_status :: :many_seats_available | :few_seats_available | :full | nil + @type occupancy_status :: + :many_seats_available + | :few_seats_available + | :standing_room_only + | :crushed_standing_room_only + | :full + | :no_data_available + | :not_accepting_passengers + | nil @type t :: %__MODULE__{ id: String.t(), @@ -19,7 +28,8 @@ defmodule Screens.Vehicles.Vehicle do trip_id: Screens.Trips.Trip.id() | nil, stop_id: Screens.Stops.Stop.id() | nil, parent_stop_id: Screens.Stops.Stop.id() | nil, - occupancy_status: occupancy_status + occupancy_status: occupancy_status, + carriages: list(occupancy_status) | nil } def by_route_and_direction(route_id, direction_id) do diff --git a/test/screens/v2/candidate_generator/triptych_test.exs b/test/screens/v2/candidate_generator/triptych_test.exs index b49228232..356712dff 100644 --- a/test/screens/v2/candidate_generator/triptych_test.exs +++ b/test/screens/v2/candidate_generator/triptych_test.exs @@ -1,23 +1,36 @@ defmodule Screens.V2.CandidateGenerator.TriptychTest do use ExUnit.Case, async: true - # alias Screens.Config.{Screen, V2} - # alias Screens.V2.CandidateGenerator.Triptych + alias Screens.Config.{Screen, V2} + alias Screens.V2.CandidateGenerator.Triptych - # setup do - # config = %Screen{ + setup do + config = %Screen{ + app_params: %V2.Triptych{ + evergreen_content: [], + train_crowding: %V2.TrainCrowding{ + station_id: "place-dwnxg", + direction_id: 1, + platform_position: 3, + front_car_direction: "right" + } + }, + vendor: :outfront, + device_id: "TEST", + name: "TEST", + app_id: :triptych_v2 + } - # } - - # %{config: config} - # end - - describe "screen_template/0" do + %{config: config} end - describe "header_instances/3" do - end - - describe "audio_only_instances/3" do + describe "screen_template/0" do + test "returns template" do + assert {:screen, + %{ + screen_normal: [:full_screen], + screen_split: [:first_pane, :second_pane, :third_pane] + }} == Triptych.screen_template() + end end end diff --git a/test/screens/v2/candidate_generator/widgets/train_crowding_test.exs b/test/screens/v2/candidate_generator/widgets/train_crowding_test.exs new file mode 100644 index 000000000..197af2a47 --- /dev/null +++ b/test/screens/v2/candidate_generator/widgets/train_crowding_test.exs @@ -0,0 +1,281 @@ +defmodule Screens.V2.CandidateGenerator.Widgets.TrainCrowdingTest do + use ExUnit.Case, async: true + + import Screens.V2.CandidateGenerator.Widgets.TrainCrowding + + alias Screens.Config.Screen + alias Screens.Config.V2.{TrainCrowding, Triptych} + alias Screens.Predictions.Prediction + alias Screens.Vehicles.Vehicle + alias Screens.V2.WidgetInstance.TrainCrowding, as: CrowdingWidget + + setup :setup_base + + defp setup_base(_) do + config = %Screen{ + app_params: %Triptych{ + train_crowding: %TrainCrowding{ + station_id: "place-masta", + direction_id: 1, + platform_position: 3, + front_car_direction: "right", + enabled: true + }, + evergreen_content: [] + }, + vendor: :outfront, + device_id: "TEST", + name: "TEST", + app_id: :triptych_v2 + } + + next_train_prediction = + struct(Prediction, %{ + vehicle: struct(Vehicle, %{stop_id: "10001", current_status: :incoming_at}) + }) + + location_context = %Screens.LocationContext{ + home_stop: "place-masta", + stop_sequences: [ + [ + "place-ogmnl", + "place-mlmnl", + "place-welln", + "place-astao", + "place-sull", + "place-ccmnl", + "place-north", + "place-haecl", + "place-state", + "place-dwnxg", + "place-chncl", + "place-tumnl", + "place-bbsta", + "place-masta", + "place-rugg", + "place-rcmnl", + "place-jaksn", + "place-sbmnl", + "place-grnst", + "place-forhl" + ] + ], + upstream_stops: + MapSet.new([ + "place-astao", + "place-bbsta", + "place-ccmnl", + "place-chncl", + "place-dwnxg", + "place-haecl", + "place-mlmnl", + "place-north", + "place-ogmnl", + "place-state", + "place-sull", + "place-tumnl", + "place-welln" + ]), + downstream_stops: + MapSet.new([ + "place-forhl", + "place-grnst", + "place-jaksn", + "place-rcmnl", + "place-rugg", + "place-sbmnl" + ]), + routes: [ + %{ + active?: true, + direction_destinations: ["Forest Hills", "Oak Grove"], + long_name: "Orange Line", + route_id: "Orange", + short_name: "", + type: :subway + } + ], + alert_route_types: [:light_rail, :subway] + } + + alerts = [ + %Screens.Alerts.Alert{ + id: "141245", + cause: :unknown, + effect: :shuttle, + severity: 7, + header: "Shuttle buses replacing Orange Line service", + informed_entities: [ + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70012"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70013"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70014"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70015"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70016"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70017"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70018"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70019"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70020"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70021"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70022"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70023"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70024"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "70025"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "place-bbsta"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "place-chncl"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "place-dwnxg"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "place-haecl"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "place-masta"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "place-state"}, + %{direction_id: nil, route: "Orange", route_type: 1, stop: "place-tumnl"} + ], + active_period: [{~U[2023-08-16 20:04:00Z], ~U[2023-08-16 22:04:06Z]}], + lifecycle: "NEW", + timeframe: nil, + created_at: ~U[2023-08-16 20:04:02Z], + updated_at: ~U[2023-08-16 20:04:02Z], + url: nil, + description: + "Affected stops:\r\nHaymarket\r\nState\r\nDowntown Crossing\r\nChinatown\r\nTufts Medical Center\r\nBack Bay\r\nMassachusetts Avenue" + } + ] + + %{ + config: config, + now: ~U[2023-08-16 21:04:00Z], + next_train_prediction: next_train_prediction, + fetch_predictions_fn: fn _ -> {:ok, [next_train_prediction]} end, + fetch_location_context_fn: fn _, _, _ -> {:ok, location_context} end, + fetch_parent_stop_id_fn: fn "10001" -> "place-masta" end, + fetch_empty_alerts_fn: fn _ -> {:ok, []} end, + fetch_alerts_fn: fn _ -> {:ok, alerts} end + } + end + + defp disable_widget(config) do + %{ + config + | app_params: %{ + config.app_params + | train_crowding: %{config.app_params.train_crowding | enabled: false} + } + } + end + + describe "crowding_widget_instances/3" do + test "returns crowding widget if train is on the way to this station", context do + assert crowding_widget_instances( + context.config, + context.now, + context.fetch_predictions_fn, + context.fetch_location_context_fn, + context.fetch_parent_stop_id_fn, + context.fetch_empty_alerts_fn + ) == [ + %CrowdingWidget{ + screen: context.config, + prediction: context.next_train_prediction, + now: context.now + } + ] + end + + test "returns empty if train is not coming yet", context do + alt_prediction = + struct(Prediction, %{ + vehicle: struct(Vehicle, %{stop_id: "9999", current_status: :incoming_at}) + }) + + assert crowding_widget_instances( + context.config, + context.now, + fn _ -> {:ok, [alt_prediction]} end, + context.fetch_location_context_fn, + fn "9999" -> "place-bbsta" end, + context.fetch_empty_alerts_fn + ) == [] + end + + test "returns empty if train has already arrived at this station", context do + alt_prediction = + struct(Prediction, %{ + vehicle: struct(Vehicle, %{stop_id: "10001", current_status: :stopped_at}) + }) + + assert crowding_widget_instances( + context.config, + context.now, + fn _ -> {:ok, [alt_prediction]} end, + context.fetch_location_context_fn, + context.fetch_parent_stop_id_fn, + context.fetch_empty_alerts_fn + ) == [] + end + + test "returns empty if there is a shuttle / suspension that makes this station a temp terminal", + context do + assert crowding_widget_instances( + context.config, + context.now, + context.fetch_predictions_fn, + context.fetch_location_context_fn, + context.fetch_parent_stop_id_fn, + context.fetch_alerts_fn + ) == [] + end + + test "returns empty if there are no predictions", context do + assert crowding_widget_instances( + context.config, + context.now, + fn _ -> {:ok, []} end, + context.fetch_location_context_fn, + context.fetch_parent_stop_id_fn, + context.fetch_empty_alerts_fn + ) == [] + end + + test "returns empty if any fetches fail", context do + assert crowding_widget_instances( + context.config, + context.now, + fn _ -> :error end, + context.fetch_location_context_fn, + context.fetch_parent_stop_id_fn, + context.fetch_empty_alerts_fn + ) == [] + + assert crowding_widget_instances( + context.config, + context.now, + context.fetch_predictions_fn, + fn _, _, _ -> :error end, + context.fetch_parent_stop_id_fn, + context.fetch_empty_alerts_fn + ) == [] + + assert crowding_widget_instances( + context.config, + context.now, + context.fetch_predictions_fn, + context.fetch_location_context_fn, + fn _ -> :error end, + context.fetch_empty_alerts_fn + ) == [] + + assert crowding_widget_instances( + context.config, + context.now, + context.fetch_predictions_fn, + context.fetch_location_context_fn, + context.fetch_parent_stop_id_fn, + fn _ -> :error end + ) == [] + end + + test "returns empty if widget is disabled", %{config: config} do + config = disable_widget(config) + + assert crowding_widget_instances(config) == [] + end + end +end diff --git a/test/screens/v2/widget_instance/train_crowding_test.exs b/test/screens/v2/widget_instance/train_crowding_test.exs new file mode 100644 index 000000000..1f13fd458 --- /dev/null +++ b/test/screens/v2/widget_instance/train_crowding_test.exs @@ -0,0 +1,113 @@ +defmodule Screens.V2.WidgetInstance.TrainCrowdingTest do + use ExUnit.Case, async: true + + alias Screens.Predictions.Prediction + alias Screens.V2.WidgetInstance.TrainCrowding, as: WidgetInstance + alias Screens.Vehicles.Vehicle + + setup do + config = + struct(Screens.Config.Screen, %{ + app_params: + struct(Screens.Config.V2.Triptych, %{ + train_crowding: %Screens.Config.V2.TrainCrowding{ + station_id: "place-masta", + direction_id: 1, + platform_position: 3, + front_car_direction: "right", + enabled: true + } + }) + }) + + prediction = + struct(Prediction, %{ + trip: %{ + headsign: "Oak Grove" + }, + vehicle: + struct(Vehicle, %{ + stop_id: "10001", + current_status: :incoming_at, + carriages: [ + :crushed_standing_room_only, + :few_seats_available, + :standing_room_only, + :many_seats_available, + :full, + :not_accepting_passengers + ] + }) + }) + + widget = %WidgetInstance{ + screen: config, + prediction: prediction, + now: ~U[2023-08-16 21:04:00Z] + } + + %{widget: widget} + end + + describe "serialize/1" do + test "serializes data", %{widget: widget} do + expected = %{ + destination: "Oak Grove", + crowding: [:crowded, :not_crowded, :some_crowding, :not_crowded, :crowded, :disabled], + platform_position: 3, + front_car_direction: "right", + now: "2023-08-16T21:04:00Z" + } + + assert expected == WidgetInstance.serialize(widget) + end + end + + describe "priority/1" do + test "returns max priority", %{widget: widget} do + assert [1] == WidgetInstance.priority(widget) + end + end + + describe "slot_names/1" do + test "returns [:full_screen]", %{widget: widget} do + assert [:full_screen] == WidgetInstance.slot_names(widget) + end + end + + describe "widget_type/1" do + test "returns :train_crowding", %{widget: widget} do + assert :train_crowding == WidgetInstance.widget_type(widget) + end + end + + describe "valid_candidate?/1" do + test "returns true", %{widget: widget} do + assert WidgetInstance.valid_candidate?(widget) + end + end + + describe "audio_serialize/1" do + test "returns empty", %{widget: widget} do + assert %{} == WidgetInstance.audio_serialize(widget) + end + end + + describe "audio_sort_key/1" do + test "returns [0]", %{widget: widget} do + assert [0] == WidgetInstance.audio_sort_key(widget) + end + end + + describe "audio_valid_candidate?/1" do + test "returns false", %{widget: widget} do + assert not WidgetInstance.audio_valid_candidate?(widget) + end + end + + describe "audio_view/1" do + test "returns nil", %{widget: widget} do + assert nil == WidgetInstance.audio_view(widget) + end + end +end