diff --git a/assets/css/colors.scss b/assets/css/colors.scss index 71753a1d3..b4b0fb251 100644 --- a/assets/css/colors.scss +++ b/assets/css/colors.scss @@ -9,6 +9,7 @@ $line-color-ferry: #008eaa; $alert-yellow: #fd0; +$true-grey-45: #737373; $true-grey-70: #b2b1af; $warm-neutral-80: #cccbc8; diff --git a/assets/css/elevator_v2.scss b/assets/css/elevator_v2.scss index c874acaab..cdc11dd92 100644 --- a/assets/css/elevator_v2.scss +++ b/assets/css/elevator_v2.scss @@ -6,6 +6,7 @@ @import "v2/lcd_common/simulation"; @import "v2/elevator/header"; @import "v2/elevator/footer"; +@import "v2/lcd_common/route_pill"; body { margin: 0; diff --git a/assets/css/v2/elevator/elevator_closures.scss b/assets/css/v2/elevator/elevator_closures.scss index b8f33e3b7..d81e94555 100644 --- a/assets/css/v2/elevator/elevator_closures.scss +++ b/assets/css/v2/elevator/elevator_closures.scss @@ -8,32 +8,126 @@ .in-station-summary { display: flex; + gap: 82px; justify-content: space-between; - padding: 24px 58px; + padding: 24px 48px; font-size: 48px; font-weight: 400; line-height: 64px; color: $cool-black-30; } - hr { - width: 100%; - height: 24px; - margin-top: 0; - margin-bottom: 0; + hr.thick { + min-height: 24px; + margin: 0; background-color: $cool-black-15; + border: none; + } + + hr.thin { + min-height: 2px; + margin: 24px 0 0; + background-color: $true-grey-45; + border: none; + opacity: 0.5; } .outside-alert-list { + position: relative; height: 100%; + font-family: Inter; background-color: $warm-neutral-90; - .header { + .header-container { + margin: 48px; + margin-bottom: 0; + + .header { + display: flex; + max-height: 432px; + font-size: 112px; + font-weight: 700; + line-height: 112px; + + &__title { + word-spacing: 9999px; + } + } + } + + .alert-list-container { + overflow: hidden; + + .alert-list { + display: flex; + flex-flow: column wrap; + height: 904px; + transform: translateX(calc(-100% * var(--alert-list-offset))); + + .alert-row { + margin: 24px 48px 0; + + &__station-name { + font-size: 62px; + font-weight: 600; + line-height: 80px; + color: $cool-black-15; + } + + &__name-and-pills { + display: flex; + gap: 24px; + align-items: center; + margin-bottom: 14px; + + .route-pill { + width: 132px; + height: 68.13px; + + & > * { + height: 100%; + } + } + } + + &__elevator-name { + line-height: 64px; + } + + &__elevator-name.list-item { + display: list-item; + margin-bottom: 8px; + margin-left: 48px; + } + } + } + } + + .paging-info-container { + position: absolute; + bottom: 0; display: flex; - padding: 48px; - font-size: 150px; - font-weight: 700; - line-height: 150px; + justify-content: space-between; + width: 100%; + height: 72px; + + & > * { + margin: 0 48px 12px; + } + + .paging-indicators { + display: flex; + gap: 27px; + align-items: center; + margin-right: 66px; + } + } + + .paging-info-container, + .alert-row__elevator-name { + font-size: 48px; + font-weight: 400; + color: $cool-black-30; } } } diff --git a/assets/src/components/v2/elevator/elevator_closures.tsx b/assets/src/components/v2/elevator/elevator_closures.tsx index 0e7b668c6..9374e29ec 100644 --- a/assets/src/components/v2/elevator/elevator_closures.tsx +++ b/assets/src/components/v2/elevator/elevator_closures.tsx @@ -1,9 +1,65 @@ -import React from "react"; +import React, { + ComponentType, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; +import cx from "classnames"; import NormalService from "Images/svgr_bundled/normal-service.svg"; import AccessibilityAlert from "Images/svgr_bundled/accessibility-alert.svg"; +import PagingDotUnselected from "Images/svgr_bundled/paging_dot_unselected.svg"; +import PagingDotSelected from "Images/svgr_bundled/paging_dot_selected.svg"; +import makePersistent, { WrappedComponentProps } from "../persistent_wrapper"; +import RoutePill, { routePillKey, type Pill } from "../departures/route_pill"; + +type StationWithAlert = { + id: string; + name: string; + routes: Pill[]; + alerts: ElevatorClosure[]; +}; + +type ElevatorClosure = { + id: string; + elevator_name: string; + elevator_id: string; + description: string; + header_text: string; +}; + +interface AlertRowProps { + station: StationWithAlert; +} + +const AlertRow = ({ station }: AlertRowProps) => { + const { name, alerts, routes, id } = station; + + return ( +
+
+ {routes.map((route) => ( + + ))} +
{name}
+
+ {alerts.map((alert) => ( +
1, + })} + > + {alert.elevator_name} ({alert.elevator_id}) +
+ ))} +
+
+ ); +}; interface InStationSummaryProps { - alerts: string[]; + alerts: ElevatorClosure[]; } const InStationSummary = ({ alerts }: InStationSummaryProps) => { @@ -19,44 +75,138 @@ const InStationSummary = ({ alerts }: InStationSummaryProps) => { -
+
); }; -interface OutsideAlertListProps { - alerts: string[]; +interface OutsideAlertListProps extends WrappedComponentProps { + stations: StationWithAlert[]; + lastUpdate: number | null; } -const OutsideAlertList = (_props: OutsideAlertListProps) => { +const OutsideAlertList = ({ + stations, + lastUpdate, + onFinish, +}: OutsideAlertListProps) => { + const [isFirstRender, setIsFirstRender] = useState(true); + const [pageIndex, setPageIndex] = useState(0); + + // Each value represents the pageIndex the row is visible on + const [rowPageIndexes, setRowPageIndexes] = useState([]); + + const [numPages, numOffsetRows] = useMemo( + () => [ + rowPageIndexes.filter((val, i, self) => self.indexOf(val) === i).length, + rowPageIndexes.filter((offset) => offset !== pageIndex).length, + ], + [rowPageIndexes], + ); + + useEffect(() => { + if (lastUpdate != null) { + if (isFirstRender) { + setIsFirstRender(false); + } else { + setPageIndex((i) => i + 1); + } + } + }, [lastUpdate]); + + useEffect(() => { + if (pageIndex === numPages - 1) { + onFinish(); + } + }, [pageIndex]); + + useLayoutEffect(() => { + const alertRows = Array.from(document.getElementsByClassName("alert-row")); + const screenWidth = 1080; + const totalXMargins = 48; + + const rowPageIndexes = alertRows.map((alert) => { + const val = (alert as HTMLDivElement).offsetLeft - totalXMargins; + return val / screenWidth; + }); + + setRowPageIndexes(rowPageIndexes); + }, [stations]); + + const getPagingIndicators = (num: number) => { + const indicators: JSX.Element[] = []; + for (let i = 0; i < num; i++) { + const indicator = + pageIndex === i ? ( + + ) : ( + + ); + indicators.push(indicator); + } + + return indicators; + }; + return (
-
- MBTA Elevator Closures - - - +
+
+
MBTA Elevator Closures
+
+ +
+
+
+
+
+ { +
+ {stations.map((station) => ( + + ))} +
+ } +
+
+
+{numOffsetRows} more elevators
+
{getPagingIndicators(numPages)}
); }; -interface Props { +interface Props extends WrappedComponentProps { id: string; - in_station_alerts: string[]; - outside_alerts: string[]; + in_station_alerts: ElevatorClosure[]; + other_stations_with_alerts: StationWithAlert[]; } const ElevatorClosures: React.ComponentType = ({ + other_stations_with_alerts: otherStationsWithAlerts, in_station_alerts: inStationAlerts, - outside_alerts: outsideAlerts, + lastUpdate, + onFinish, }: Props) => { return (
- +
); }; -export default ElevatorClosures; +export default makePersistent( + ElevatorClosures as ComponentType, +); diff --git a/assets/static/images/svgr_bundled/paging_dot_selected.svg b/assets/static/images/svgr_bundled/paging_dot_selected.svg new file mode 100644 index 000000000..44cdf0c59 --- /dev/null +++ b/assets/static/images/svgr_bundled/paging_dot_selected.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/static/images/svgr_bundled/paging_dot_unselected.svg b/assets/static/images/svgr_bundled/paging_dot_unselected.svg new file mode 100644 index 000000000..9ce2cd659 --- /dev/null +++ b/assets/static/images/svgr_bundled/paging_dot_unselected.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/config/test.exs b/config/test.exs index 21faa85b7..d47c5d39e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -170,6 +170,12 @@ config :screens, Screens.V2.RDS, route_pattern_module: Screens.RoutePatterns.MockRoutePattern, stop_module: Screens.Stops.MockStop +config :screens, Screens.V2.CandidateGenerator.Elevator.Closures, + stop_module: Screens.Stops.MockStop, + facility_module: Screens.Facilities.MockFacility, + alert_module: Screens.Alerts.MockAlert, + route_module: Screens.Routes.MockRoute + config :screens, Screens.LastTrip, trip_updates_adapter: Screens.LastTrip.TripUpdates.Noop, vehicle_positions_adapter: Screens.LastTrip.VehiclePositions.Noop diff --git a/lib/screens/alerts/alert.ex b/lib/screens/alerts/alert.ex index ae342482c..19dc4ac31 100644 --- a/lib/screens/alerts/alert.ex +++ b/lib/screens/alerts/alert.ex @@ -1,7 +1,7 @@ defmodule Screens.Alerts.Alert do @moduledoc false - alias Screens.Alerts.InformedEntity + alias Screens.Alerts.{Alert, InformedEntity} alias Screens.Routes.Route alias Screens.RouteType alias Screens.Stops.Stop @@ -204,6 +204,7 @@ defmodule Screens.Alerts.Alert do end end + @callback fetch_elevator_alerts_with_facilities() :: {:ok, list(Alert.t())} | :error def fetch_elevator_alerts_with_facilities(get_json_fn \\ &V3Api.get_json/2) do query_opts = [activity: "USING_WHEELCHAIR", include: ~w[facilities]] diff --git a/lib/screens/facilities/facility.ex b/lib/screens/facilities/facility.ex new file mode 100644 index 000000000..3bf1e8ffb --- /dev/null +++ b/lib/screens/facilities/facility.ex @@ -0,0 +1,22 @@ +defmodule Screens.Facilities.Facility do + @moduledoc """ + Functions for fetching facility data from the V3 API. + """ + + alias Screens.Stops + + @type id :: String.t() + + @callback fetch_stop_for_facility(id()) :: {:ok, Stops.Stop.t()} | {:error, term()} + def fetch_stop_for_facility(facility_id) do + case Screens.V3Api.get_json("facilities/#{facility_id}", %{ + "include" => "stop" + }) do + {:ok, %{"data" => _data, "included" => [stop_map]}} -> + {:ok, Stops.Parser.parse_stop(stop_map)} + + error -> + {:error, error} + end + end +end diff --git a/lib/screens/routes/route.ex b/lib/screens/routes/route.ex index 78e2825c6..fc4386f34 100644 --- a/lib/screens/routes/route.ex +++ b/lib/screens/routes/route.ex @@ -48,8 +48,8 @@ defmodule Screens.Routes.Route do end end - @spec fetch() :: {:ok, [t()]} | :error - @spec fetch(params()) :: {:ok, [t()]} | :error + @callback fetch() :: {:ok, [t()]} | :error + @callback fetch(params()) :: {:ok, [t()]} | :error def fetch(opts \\ %{}, get_json_fn \\ &V3Api.get_json/2) do params = opts diff --git a/lib/screens/stops/stop.ex b/lib/screens/stops/stop.ex index c935a130a..82abadb0d 100644 --- a/lib/screens/stops/stop.ex +++ b/lib/screens/stops/stop.ex @@ -17,7 +17,7 @@ defmodule Screens.Stops.Stop do alias Screens.Stops alias Screens.Util alias Screens.V3Api - alias ScreensConfig.V2.{BusEink, BusShelter, Dup, GlEink, PreFare} + alias ScreensConfig.V2.{BusEink, BusShelter, Dup, Elevator, GlEink, PreFare} defstruct ~w[id name location_type platform_code platform_name]a @@ -251,6 +251,7 @@ defmodule Screens.Stops.Stop do # --- These functions involve the API --- + @callback fetch_parent_station_name_map() :: {:ok, list(%{String.t() => String.t()})} | :error def fetch_parent_station_name_map(get_json_fn \\ &V3Api.get_json/2) do case get_json_fn.("stops", %{ "filter[location_type]" => 1 @@ -442,11 +443,11 @@ defmodule Screens.Stops.Stop do @doc """ Fetches all the location context for a screen given its app type, stop id, and time """ - @spec fetch_location_context( - screen_type(), - id(), - DateTime.t() - ) :: {:ok, LocationContext.t()} | :error + @callback fetch_location_context( + screen_type(), + id(), + DateTime.t() + ) :: {:ok, LocationContext.t()} | :error def fetch_location_context(app, stop_id, now) do Screens.Telemetry.span( ~w[screens stops stop fetch_location_context]a, @@ -493,6 +494,7 @@ defmodule Screens.Stops.Stop do # 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] + def get_route_type_filter(Elevator, _), do: [:subway] @spec upstream_stop_id_set(String.t(), list(list(id()))) :: MapSet.t(id()) def upstream_stop_id_set(stop_id, stop_sequences) do @@ -530,7 +532,8 @@ defmodule Screens.Stops.Stop do RoutePattern.fetch_tagged_parent_station_sequences_through_stop(stop_id, route_ids) end - defp fetch_tagged_stop_sequences_by_app(PreFare, stop_id, routes_at_stop) do + defp fetch_tagged_stop_sequences_by_app(app, stop_id, routes_at_stop) + when app in [Elevator, PreFare] do route_ids = Route.route_ids(routes_at_stop) # We limit results to canonical route patterns only--no stop sequences for nonstandard patterns. diff --git a/lib/screens/v2/candidate_generator/elevator.ex b/lib/screens/v2/candidate_generator/elevator.ex index eeefbdb50..75f47458e 100644 --- a/lib/screens/v2/candidate_generator/elevator.ex +++ b/lib/screens/v2/candidate_generator/elevator.ex @@ -2,8 +2,9 @@ defmodule Screens.V2.CandidateGenerator.Elevator do @moduledoc false alias Screens.V2.CandidateGenerator + alias Screens.V2.CandidateGenerator.Elevator.Closures, as: ElevatorClosures alias Screens.V2.Template.Builder - alias Screens.V2.WidgetInstance.{ElevatorClosures, Footer, NormalHeader} + alias Screens.V2.WidgetInstance.{Footer, NormalHeader} alias ScreensConfig.Screen alias ScreensConfig.V2.Elevator @@ -23,16 +24,17 @@ defmodule Screens.V2.CandidateGenerator.Elevator do |> Builder.build_template() end - def candidate_instances(config, now \\ DateTime.utc_now()) do - [header_instance(config, now), elevator_closures_instance(config), footer_instance(config)] + def candidate_instances( + config, + now \\ DateTime.utc_now(), + elevator_closure_instances_fn \\ &ElevatorClosures.elevator_status_instances/2 + ) do + [header_instance(config, now), footer_instance(config)] ++ + elevator_closure_instances_fn.(config, now) end def audio_only_instances(_widgets, _config), do: [] - defp elevator_closures_instance(config) do - %ElevatorClosures{screen: config, alerts: []} - end - defp header_instance(%Screen{app_params: %Elevator{elevator_id: elevator_id}} = config, now) do %NormalHeader{text: "Elevator #{elevator_id}", screen: config, time: now} end diff --git a/lib/screens/v2/candidate_generator/elevator/closures.ex b/lib/screens/v2/candidate_generator/elevator/closures.ex new file mode 100644 index 000000000..f83165e72 --- /dev/null +++ b/lib/screens/v2/candidate_generator/elevator/closures.ex @@ -0,0 +1,160 @@ +defmodule Screens.V2.CandidateGenerator.Elevator.Closures do + @moduledoc false + + require Logger + + alias Screens.Alerts.{Alert, InformedEntity} + alias Screens.Facilities.Facility + alias Screens.Routes.Route + alias Screens.Stops.Stop + alias Screens.V2.WidgetInstance.ElevatorClosures + alias Screens.V2.WidgetInstance.Serializer.RoutePill + alias ScreensConfig.Screen + alias ScreensConfig.V2.Elevator + + @stop Application.compile_env(:screens, [__MODULE__, :stop_module], Stop) + @facility Application.compile_env(:screens, [__MODULE__, :facility_module], Facility) + @alert Application.compile_env(:screens, [__MODULE__, :alert_module], Alert) + @route Application.compile_env(:screens, [__MODULE__, :route_module], Route) + + @spec elevator_status_instances(Screen.t()) :: list(ElevatorClosures.t()) + @spec elevator_status_instances(Screen.t(), DateTime.t()) :: list(ElevatorClosures.t()) + def elevator_status_instances( + %Screen{ + app_params: %Elevator{ + elevator_id: elevator_id + } + }, + now \\ DateTime.utc_now() + ) do + with {:ok, %Stop{id: stop_id}} <- @facility.fetch_stop_for_facility(elevator_id), + {:ok, location_context} <- @stop.fetch_location_context(Elevator, stop_id, now), + {:ok, parent_station_map} <- @stop.fetch_parent_station_name_map(), + {:ok, alerts} <- @alert.fetch_elevator_alerts_with_facilities() do + elevator_closures = relevant_alerts(alerts) + routes_map = get_routes_map(elevator_closures, stop_id) + + {in_station_alerts, outside_alerts} = + split_alerts_by_location(elevator_closures, location_context) + + [ + %ElevatorClosures{ + id: elevator_id, + in_station_alerts: Enum.map(in_station_alerts, &alert_to_elevator_closure/1), + other_stations_with_alerts: + format_outside_alerts(outside_alerts, parent_station_map, routes_map) + } + ] + else + :error -> + [] + + {:error, error} -> + Logger.error("[elevator_status_instances] #{inspect(error)}") + [] + end + end + + defp relevant_alerts(alerts) do + Enum.filter(alerts, &(&1.effect == :elevator_closure)) + end + + defp get_routes_map(elevator_closures, home_parent_station_id) do + elevator_closures + |> get_parent_station_ids_from_entities() + |> MapSet.new() + |> MapSet.put(home_parent_station_id) + |> Enum.map(fn station_id -> + {station_id, station_id |> route_ids_serving_stop() |> routes_to_labels()} + end) + |> Enum.into(%{}) + end + + defp get_parent_station_ids_from_entities(alerts) do + alerts + |> Enum.flat_map(fn %Alert{informed_entities: informed_entities} -> + informed_entities + |> Enum.map(fn %{stop: stop_id} -> stop_id end) + |> Enum.filter(&String.starts_with?(&1, "place-")) + end) + end + + defp route_ids_serving_stop(stop_id) do + case @route.fetch(%{stop_id: stop_id}) do + {:ok, routes} -> routes + # Show no route pills instead of crashing the screen + :error -> [] + end + end + + defp routes_to_labels(routes) do + routes + |> Enum.map(fn + %Route{type: :subway, id: id} -> id |> String.downcase() |> String.to_atom() + %Route{type: :light_rail, id: "Green-" <> _} -> :green + %Route{type: :light_rail, id: "Mattapan" <> _} -> :mattapan + %Route{type: :bus, short_name: "SL" <> _} -> :silver + %Route{type: :rail} -> :cr + %Route{type: type} -> type + end) + |> Enum.uniq() + end + + defp split_alerts_by_location(alerts, location_context) do + Enum.split_with(alerts, fn %Alert{informed_entities: informed_entities} -> + location_context.home_stop in Enum.map(informed_entities, & &1.stop) + end) + end + + defp get_informed_facility(entities) do + entities + |> Enum.find_value(fn + %{facility: facility} -> facility + _ -> false + end) + end + + defp alert_to_elevator_closure(%Alert{ + id: id, + informed_entities: entities, + description: description, + header: header + }) do + facility = get_informed_facility(entities) + + %{ + id: id, + elevator_name: facility.name, + elevator_id: facility.id, + description: description, + header_text: header + } + end + + defp format_outside_alerts(alerts, station_id_to_name, station_id_to_routes) do + alerts + |> Enum.group_by(&get_parent_station_id_from_informed_entities(&1.informed_entities)) + |> Enum.map(fn {parent_station_id, alerts} -> + alerts_at_station = Enum.map(alerts, &alert_to_elevator_closure/1) + + route_pills = + station_id_to_routes + |> Map.fetch!(parent_station_id) + |> Enum.map(&RoutePill.serialize_icon/1) + + %{ + id: parent_station_id, + name: Map.fetch!(station_id_to_name, parent_station_id), + routes: route_pills, + alerts: alerts_at_station + } + end) + end + + defp get_parent_station_id_from_informed_entities(entities) do + entities + |> Enum.find_value(fn + ie -> if InformedEntity.parent_station?(ie), do: ie.stop + end) + end +end diff --git a/lib/screens/v2/screen_data/parameters.ex b/lib/screens/v2/screen_data/parameters.ex index 2169f757a..bdea93454 100644 --- a/lib/screens/v2/screen_data/parameters.ex +++ b/lib/screens/v2/screen_data/parameters.ex @@ -38,7 +38,7 @@ defmodule Screens.V2.ScreenData.Parameters do }, elevator_v2: %Static{ candidate_generator: CandidateGenerator.Elevator, - refresh_rate: 30 + refresh_rate: 8 }, gl_eink_v2: %Static{ audio_active_time: @all_times, diff --git a/lib/screens/v2/widget_instance/elevator_closures.ex b/lib/screens/v2/widget_instance/elevator_closures.ex index 59f612653..3197bbf3d 100644 --- a/lib/screens/v2/widget_instance/elevator_closures.ex +++ b/lib/screens/v2/widget_instance/elevator_closures.ex @@ -1,22 +1,60 @@ defmodule Screens.V2.WidgetInstance.ElevatorClosures do @moduledoc false - alias Screens.Alerts.Alert - alias ScreensConfig.Screen - alias ScreensConfig.V2.Elevator + alias Screens.Stops.Stop - defstruct screen: nil, - alerts: nil + defstruct ~w[id in_station_alerts other_stations_with_alerts]a @type t :: %__MODULE__{ - screen: Screen.t(), - alerts: list(Alert.t()) + id: String.t(), + in_station_alerts: list(__MODULE__.Alert.t()), + other_stations_with_alerts: list(__MODULE__.Station.t()) } - def serialize(%__MODULE__{screen: %Screen{app_params: %Elevator{elevator_id: id}}}) do - %{id: id, in_station_alerts: [], outside_alerts: []} + defmodule Station do + @moduledoc false + + alias Screens.V2.WidgetInstance.ElevatorClosures.Alert + + @derive Jason.Encoder + + defstruct ~w[id name routes alerts]a + + @type t :: %__MODULE__{ + id: Stop.id(), + name: String.t(), + routes: list(String.t()), + alerts: list(Alert.t()) + } + end + + defmodule Alert do + @moduledoc false + + @derive Jason.Encoder + + defstruct ~w[id elevator_name elevator_id description header_text]a + + @type t :: %__MODULE__{ + id: String.t(), + elevator_name: String.t(), + elevator_id: String.t(), + description: String.t(), + header_text: String.t() + } end + def serialize(%__MODULE__{ + id: id, + in_station_alerts: in_station_alerts, + other_stations_with_alerts: other_stations_with_alerts + }), + do: %{ + id: id, + in_station_alerts: in_station_alerts, + other_stations_with_alerts: other_stations_with_alerts + } + defimpl Screens.V2.WidgetInstance do alias Screens.V2.WidgetInstance.ElevatorClosures diff --git a/test/screens/v2/candidate_generator/elevator/closures_test.exs b/test/screens/v2/candidate_generator/elevator/closures_test.exs new file mode 100644 index 000000000..4ea01b3c5 --- /dev/null +++ b/test/screens/v2/candidate_generator/elevator/closures_test.exs @@ -0,0 +1,210 @@ +defmodule Screens.V2.CandidateGenerator.Elevator.ClosuresTest do + use ExUnit.Case, async: true + + import Mox + setup :verify_on_exit! + + alias Screens.Alerts.{Alert, MockAlert} + alias Screens.Facilities.MockFacility + alias Screens.LocationContext + alias Screens.Routes.{MockRoute, Route} + alias Screens.Stops.{MockStop, Stop} + alias Screens.V2.CandidateGenerator.Elevator.Closures, as: ElevatorClosures + alias ScreensConfig.Screen + alias ScreensConfig.V2.Elevator + + describe "elevator_status_instances/5" do + test "Only returns alerts with effect of :elevator_closure" do + now = ~U[2024-10-01T05:00:00Z] + + expect(MockFacility, :fetch_stop_for_facility, fn "111" -> + {:ok, %Stop{id: "place-test"}} + end) + + expect(MockStop, :fetch_location_context, fn Elevator, "place-test", ^now -> + {:ok, %LocationContext{home_stop: "place-test"}} + end) + + expect(MockStop, :fetch_parent_station_name_map, fn -> + {:ok, %{"place-test" => "Place Test"}} + end) + + expect(MockRoute, :fetch, fn %{stop_id: "place-test"} -> + {:ok, [%Route{id: "Red", type: :subway}]} + end) + + expect(MockAlert, :fetch_elevator_alerts_with_facilities, fn -> + alerts = [ + struct(Alert, + id: "1", + effect: :elevator_closure, + informed_entities: [ + %{stop: "place-test", facility: %{name: "Test", id: "facility-test"}} + ] + ), + struct(Alert, + effect: :detour, + informed_entities: [ + %{stop: "place-test", facility: %{name: "Test 2", id: "facility-test2"}} + ] + ) + ] + + {:ok, alerts} + end) + + [ + %Screens.V2.WidgetInstance.ElevatorClosures{ + id: "111", + in_station_alerts: [ + %{ + id: "1", + description: nil, + elevator_name: "Test", + elevator_id: "facility-test", + header_text: nil + } + ], + other_stations_with_alerts: [] + } + ] = + ElevatorClosures.elevator_status_instances( + struct(Screen, app_id: :elevator_v2, app_params: %Elevator{elevator_id: "111"}), + now + ) + end + + test "Groups outside alerts by station" do + now = ~U[2024-10-01T05:00:00Z] + + expect(MockFacility, :fetch_stop_for_facility, fn "111" -> + {:ok, %Stop{id: "place-test"}} + end) + + expect(MockStop, :fetch_location_context, fn Elevator, "place-test", ^now -> + {:ok, %LocationContext{home_stop: "place-test"}} + end) + + expect(MockStop, :fetch_parent_station_name_map, fn -> + {:ok, %{"place-haecl" => "Haymarket"}} + end) + + expect(MockRoute, :fetch, 2, fn + %{stop_id: "place-haecl"} -> + {:ok, [%Route{id: "Orange", type: :subway}]} + + %{stop_id: "place-test"} -> + {:ok, [%Route{id: "Red", type: :subway}]} + end) + + expect(MockAlert, :fetch_elevator_alerts_with_facilities, fn -> + alerts = [ + struct(Alert, + id: "1", + effect: :elevator_closure, + informed_entities: [ + %{stop: "place-haecl", facility: %{name: "Test 1", id: "facility-test-1"}} + ] + ), + struct(Alert, + id: "2", + effect: :elevator_closure, + informed_entities: [ + %{stop: "place-haecl", facility: %{name: "Test 2", id: "facility-test-2"}} + ] + ) + ] + + {:ok, alerts} + end) + + [ + %Screens.V2.WidgetInstance.ElevatorClosures{ + id: "111", + in_station_alerts: [], + other_stations_with_alerts: [ + %{ + id: "place-haecl", + name: "Haymarket", + routes: [%{type: :text, text: "OL", color: :orange}], + alerts: [ + %{ + id: "1", + description: nil, + elevator_name: "Test 1", + elevator_id: "facility-test-1", + header_text: nil + }, + %{ + id: "2", + description: nil, + elevator_name: "Test 2", + elevator_id: "facility-test-2", + header_text: nil + } + ] + } + ] + } + ] = + ElevatorClosures.elevator_status_instances( + struct(Screen, app_id: :elevator_v2, app_params: %Elevator{elevator_id: "111"}), + now + ) + end + + test "Return empty routes on API error" do + now = ~U[2024-10-01T05:00:00Z] + + expect(MockFacility, :fetch_stop_for_facility, fn "111" -> + {:ok, %Stop{id: "place-test"}} + end) + + expect(MockStop, :fetch_location_context, fn Elevator, "place-test", ^now -> + {:ok, %LocationContext{home_stop: "place-test"}} + end) + + expect(MockStop, :fetch_parent_station_name_map, fn -> + {:ok, %{"place-test" => "Place Test"}} + end) + + expect(MockRoute, :fetch, fn %{stop_id: "place-test"} -> + :error + end) + + expect(MockAlert, :fetch_elevator_alerts_with_facilities, fn -> + alerts = [ + struct(Alert, + id: "1", + effect: :elevator_closure, + informed_entities: [ + %{stop: "place-test", facility: %{name: "Test", id: "facility-test"}} + ] + ) + ] + + {:ok, alerts} + end) + + [ + %Screens.V2.WidgetInstance.ElevatorClosures{ + id: "111", + in_station_alerts: [ + %{ + id: "1", + description: nil, + elevator_name: "Test", + elevator_id: "facility-test", + header_text: nil + } + ], + other_stations_with_alerts: [] + } + ] = + ElevatorClosures.elevator_status_instances( + struct(Screen, app_id: :elevator_v2, app_params: %Elevator{elevator_id: "111"}), + now + ) + end + end +end diff --git a/test/screens/v2/widget_instance/elevator_closures_test.exs b/test/screens/v2/widget_instance/elevator_closures_test.exs index 96ef7e858..71750b1c6 100644 --- a/test/screens/v2/widget_instance/elevator_closures_test.exs +++ b/test/screens/v2/widget_instance/elevator_closures_test.exs @@ -3,14 +3,35 @@ defmodule Screens.V2.WidgetInstance.ElevatorClosuresTest do alias Screens.V2.WidgetInstance alias Screens.V2.WidgetInstance.ElevatorClosures - alias ScreensConfig.Screen - alias ScreensConfig.V2.Elevator setup do %{ instance: %ElevatorClosures{ - screen: struct(Screen, %{app_params: %Elevator{elevator_id: "111"}}), - alerts: [] + id: "111", + in_station_alerts: [ + %ElevatorClosures.Alert{ + description: "Test Alert Description", + elevator_name: "Test Elevator", + elevator_id: "111", + id: "1", + header_text: "Test Alert Header" + } + ], + other_stations_with_alerts: [ + %ElevatorClosures.Station{ + name: "Forest Hills", + routes: ["Orange"], + alerts: [ + %ElevatorClosures.Alert{ + description: "FH Alert Description", + elevator_name: "FH Elevator", + elevator_id: "222", + id: "2", + header_text: "FH Alert Header" + } + ] + } + ] } } end @@ -23,11 +44,7 @@ defmodule Screens.V2.WidgetInstance.ElevatorClosuresTest do describe "serialize/1" do test "returns map with id and alerts", %{instance: instance} do - assert %{ - id: "111", - in_station_alerts: [], - outside_alerts: [] - } == WidgetInstance.serialize(instance) + assert Map.from_struct(instance) == WidgetInstance.serialize(instance) end end diff --git a/test/support/mocks.ex b/test/support/mocks.ex index d035ff3b4..4cad77793 100644 --- a/test/support/mocks.ex +++ b/test/support/mocks.ex @@ -3,3 +3,6 @@ Mox.defmock(Screens.RoutePatterns.MockRoutePattern, for: Screens.RoutePatterns.R Mox.defmock(Screens.Stops.MockStop, for: Screens.Stops.Stop) Mox.defmock(Screens.V2.MockDeparture, for: Screens.V2.Departure) Mox.defmock(Screens.V2.ScreenData.MockParameters, for: Screens.V2.ScreenData.Parameters) +Mox.defmock(Screens.Facilities.MockFacility, for: Screens.Facilities.Facility) +Mox.defmock(Screens.Alerts.MockAlert, for: Screens.Alerts.Alert) +Mox.defmock(Screens.Routes.MockRoute, for: Screens.Routes.Route)