From 452160df052e2070074300b40d8565bda1ba04e4 Mon Sep 17 00:00:00 2001 From: Christian Maddox Date: Fri, 15 Nov 2024 15:01:33 -0500 Subject: [PATCH] feat: Elevator Closures list (#2288) * API additions needed for Elevators. * Added new CG. * Return maps with all info client will need. * Added mock support for tests. * Added CG tests. * Fixed reference to key. * Improved test. * Credo. * Logger error if one is returned. * Address PR feedback. * Simplify business logic so it all lives in CG. * Credo. * Fix serialization. * Implement list without paging. * Add paging. * Added route pills. * Fix horizontal rules. * Generate pages before rendering any alerts. * Added paging indicators. * Fix tests. * CSS styles. * Drop refresh rate to 7 seconds. * Simplify paging. * Fix horizontal rules. * Fixed paging logic. * Group alerts by station. * Renamed variable. * Added a test. * Update refresh interval. * Various spacing/sizing fixes. * Address PR feedback. * Refactored out common hooks for client paging. * Fix issue with data not updating if there is only one page. * Fixed styles on rows with short descriptions. * Simplify page index array generation. * Import LCD screen container styles. * Fix variable names. * Revert changes to LocationContext. * Tweak CSS to use compatible features. * Use structs instead of plain maps. * Couple more style tweaks. --- assets/css/colors.scss | 1 + assets/css/elevator_v2.scss | 3 + assets/css/v2/elevator/elevator_closures.scss | 137 +++++++++- .../v2/elevator/elevator_closures.tsx | 197 +++++++++++++-- .../src/components/v2/persistent_carousel.tsx | 26 +- assets/src/hooks/v2/use_client_paging.tsx | 33 +++ .../svgr_bundled/paging_dot_selected.svg | 3 + .../svgr_bundled/paging_dot_unselected.svg | 3 + config/test.exs | 6 + lib/screens/alerts/alert.ex | 3 +- lib/screens/facilities/facility.ex | 22 ++ lib/screens/routes/route.ex | 4 +- lib/screens/stops/stop.ex | 2 +- .../v2/candidate_generator/elevator.ex | 16 +- .../candidate_generator/elevator/closures.ex | 143 +++++++++++ lib/screens/v2/screen_data/parameters.ex | 2 +- .../v2/widget_instance/elevator_closures.ex | 57 ++++- .../elevator/closures_test.exs | 233 ++++++++++++++++++ .../elevator_closures_test.exs | 37 ++- test/support/mocks.ex | 3 + 20 files changed, 846 insertions(+), 85 deletions(-) create mode 100644 assets/src/hooks/v2/use_client_paging.tsx create mode 100644 assets/static/images/svgr_bundled/paging_dot_selected.svg create mode 100644 assets/static/images/svgr_bundled/paging_dot_unselected.svg create mode 100644 lib/screens/facilities/facility.ex create mode 100644 lib/screens/v2/candidate_generator/elevator/closures.ex create mode 100644 test/screens/v2/candidate_generator/elevator/closures_test.exs 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..8c0ea9d0f 100644 --- a/assets/css/elevator_v2.scss +++ b/assets/css/elevator_v2.scss @@ -1,14 +1,17 @@ @import "https://rsms.me/inter/inter.css"; @import "colors"; +@import "v2/lcd_common/screen_container"; @import "v2/elevator/elevator_closures"; @import "v2/elevator/normal"; @import "v2/simulation_common"; @import "v2/lcd_common/simulation"; @import "v2/elevator/header"; @import "v2/elevator/footer"; +@import "v2/lcd_common/route_pill"; body { margin: 0; + font-family: Inter; } .multi-screen-page { diff --git a/assets/css/v2/elevator/elevator_closures.scss b/assets/css/v2/elevator/elevator_closures.scss index b8f33e3b7..0bdca043e 100644 --- a/assets/css/v2/elevator/elevator_closures.scss +++ b/assets/css/v2/elevator/elevator_closures.scss @@ -3,37 +3,148 @@ display: flex; flex-direction: column; height: 100%; - font-family: Inter, sans-serif; background-color: $warm-neutral-90; .in-station-summary { display: flex; justify-content: space-between; - padding: 24px 58px; + padding: 24px 48px; font-size: 48px; font-weight: 400; line-height: 64px; color: $cool-black-30; + + .text { + margin-right: 82px; + } } - 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 { + .outside-closure-list { + position: relative; height: 100%; 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; + } + } + } + + .closure-list-container { + overflow: hidden; + + .closure-list { + display: flex; + flex-flow: column wrap; + height: 904px; + transform: translateX(calc(-100% * var(--closure-list-offset))); + + .closure-row { + max-width: 1080px; + padding: 0 48px; + margin-top: 24px; + + &__station-name { + font-size: 62px; + font-weight: 600; + line-height: 80px; + color: $cool-black-15; + } + + &__name-and-pills { + display: flex; + align-items: center; + margin-bottom: 14px; + + .route-pill { + width: 132px; + height: 68.13px; + margin-right: 24px; + + &__text { + line-height: 68.13px; + } + + &__icon { + height: 100%; + } + + &__icon-image { + height: 100%; + margin-top: 0; + } + } + } + + &__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-bottom: 20px; + + .more-elevators-text { + align-self: center; + padding: 0 48px 20px; + } + + .paging-indicators { + display: flex; + align-items: center; + margin-right: 66px; + + .paging-indicator:first-child:not(:only-child) { + margin-right: 27px; + } + } + } + + .paging-info-container, + .closure-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..4da4503e1 100644 --- a/assets/src/components/v2/elevator/elevator_closures.tsx +++ b/assets/src/components/v2/elevator/elevator_closures.tsx @@ -1,13 +1,65 @@ -import React from "react"; +import React, { ComponentType, useLayoutEffect, useRef, 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"; +import _ from "lodash"; +import useClientPaging from "Hooks/v2/use_client_paging"; + +type StationWithClosures = { + id: string; + name: string; + route_icons: Pill[]; + closures: ElevatorClosure[]; +}; + +type ElevatorClosure = { + id: string; + elevator_name: string; + elevator_id: string; + description: string; + header_text: string; +}; + +interface ClosureRowProps { + station: StationWithClosures; +} + +const ClosureRow = ({ station }: ClosureRowProps) => { + const { name, closures, route_icons, id } = station; + + return ( +
+
+ {route_icons.map((route) => ( + + ))} +
{name}
+
+ {closures.map((closure) => ( +
1, + })} + > + {closure.elevator_name} ({closure.elevator_id}) +
+ ))} +
+
+ ); +}; interface InStationSummaryProps { - alerts: string[]; + closures: ElevatorClosure[]; } -const InStationSummary = ({ alerts }: InStationSummaryProps) => { - const summaryText = alerts.length +const InStationSummary = ({ closures }: InStationSummaryProps) => { + const summaryText = closures.length ? "" : "All elevators at this station are currently working"; @@ -19,44 +71,145 @@ const InStationSummary = ({ alerts }: InStationSummaryProps) => { -
+
); }; -interface OutsideAlertListProps { - alerts: string[]; +interface OutsideClosureListProps extends WrappedComponentProps { + stations: StationWithClosures[]; + lastUpdate: number | null; } -const OutsideAlertList = (_props: OutsideAlertListProps) => { +const OutsideClosureList = ({ + stations, + lastUpdate, + onFinish, +}: OutsideClosureListProps) => { + const ref = useRef(null); + + // Each index represents a page number and each value represents the number of rows + // on the corresponding page index. + const [rowCounts, setRowCounts] = useState([]); + + const numPages = Object.keys(rowCounts).length; + const pageIndex = useClientPaging({ numPages, onFinish, lastUpdate }); + + const numOffsetRows = Object.keys(rowCounts).reduce((acc, key) => { + if (parseInt(key) === pageIndex) { + return acc; + } else { + return acc + rowCounts[key]; + } + }, 0); + + useLayoutEffect(() => { + if (!ref.current) return; + + const offsets = Array.from(ref.current.children).map((closure) => { + return (closure as HTMLDivElement).offsetLeft; + }); + + const rowCounts: number[] = []; + + _.uniq(offsets).forEach((uo) => { + rowCounts.push(offsets.filter((o) => o === uo).length); + }); + + setRowCounts(rowCounts); + }, [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) => ( + + ))} +
+ }
+ {numPages > 1 && ( +
+
+ +{numOffsetRows} more elevators +
+
+ {getPagingIndicators(numPages)} +
+
+ )}
); }; -interface Props { +interface Props extends WrappedComponentProps { id: string; - in_station_alerts: string[]; - outside_alerts: string[]; + in_station_closures: ElevatorClosure[]; + other_stations_with_closures: StationWithClosures[]; } const ElevatorClosures: React.ComponentType = ({ - in_station_alerts: inStationAlerts, - outside_alerts: outsideAlerts, + other_stations_with_closures: otherStationsWithClosures, + in_station_closures: inStationClosures, + lastUpdate, + onFinish, }: Props) => { return (
- - + +
); }; -export default ElevatorClosures; +export default makePersistent( + ElevatorClosures as ComponentType, +); diff --git a/assets/src/components/v2/persistent_carousel.tsx b/assets/src/components/v2/persistent_carousel.tsx index fa5899198..b75a3efe7 100644 --- a/assets/src/components/v2/persistent_carousel.tsx +++ b/assets/src/components/v2/persistent_carousel.tsx @@ -1,5 +1,6 @@ -import React, { ComponentType, ReactNode, useEffect, useState } from "react"; +import React, { ComponentType, ReactNode } from "react"; import makePersistent, { WrappedComponentProps } from "./persistent_wrapper"; +import useClientPaging from "Hooks/v2/use_client_paging"; interface PageRendererProps { page: T; @@ -18,24 +19,11 @@ const Carousel = ({ onFinish, lastUpdate, }: Props): ReactNode => { - const [isFirstRender, setIsFirstRender] = useState(true); - const [pageIndex, setPageIndex] = useState(0); - - useEffect(() => { - if (lastUpdate != null) { - if (isFirstRender) { - setIsFirstRender(false); - } else { - setPageIndex((i) => i + 1); - } - } - }, [lastUpdate]); - - useEffect(() => { - if (pageIndex === pages.length - 1) { - onFinish(); - } - }, [pageIndex]); + const pageIndex = useClientPaging({ + numPages: pages.length, + onFinish, + lastUpdate, + }); return ( { + const [pageIndex, setPageIndex] = useState(0); + const [isFirstRender, setIsFirstRender] = useState(true); + + useEffect(() => { + if (lastUpdate != null) { + if (isFirstRender) { + setIsFirstRender(false); + } else if (numPages > 1) { + setPageIndex((i) => i + 1); + } else { + onFinish(); + } + } + }, [lastUpdate]); + + useEffect(() => { + if (pageIndex === numPages - 1) { + onFinish(); + } + }, [pageIndex]); + + return pageIndex; +}; + +export default useClientPaging; 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 ea83c4637..13e0c71d8 100644 --- a/config/test.exs +++ b/config/test.exs @@ -173,6 +173,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..7ab3634bd --- /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 fac1a0078..f5a2e5512 100644 --- a/lib/screens/stops/stop.ex +++ b/lib/screens/stops/stop.ex @@ -90,7 +90,7 @@ defmodule Screens.Stops.Stop do end end - @spec fetch_parent_station_name_map() :: {:ok, %{id() => String.t()}} | :error + @callback fetch_parent_station_name_map() :: {:ok, %{id() => String.t()}} | :error def fetch_parent_station_name_map do case fetch(%{location_types: [1]}) do {:ok, stops} -> {:ok, Map.new(stops, fn %__MODULE__{id: id, name: name} -> {id, name} end)} diff --git a/lib/screens/v2/candidate_generator/elevator.ex b/lib/screens/v2/candidate_generator/elevator.ex index eeefbdb50..73cbfeffb 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/1 + ) do + [header_instance(config, now), footer_instance(config)] ++ + elevator_closure_instances_fn.(config) 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..f8dd0b9df --- /dev/null +++ b/lib/screens/v2/candidate_generator/elevator/closures.ex @@ -0,0 +1,143 @@ +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()) + def elevator_status_instances(%Screen{app_params: %Elevator{elevator_id: elevator_id}}) do + with {:ok, %Stop{id: stop_id}} <- @facility.fetch_stop_for_facility(elevator_id), + {:ok, parent_station_map} <- @stop.fetch_parent_station_name_map(), + {:ok, alerts} <- @alert.fetch_elevator_alerts_with_facilities() do + elevator_alerts = Enum.filter(alerts, &relevant_alert?/1) + routes_map = get_routes_map(elevator_alerts, stop_id) + + {in_station_alerts, outside_alerts} = + split_closures_by_location(elevator_alerts, stop_id) + + [ + %ElevatorClosures{ + id: elevator_id, + in_station_closures: Enum.map(in_station_alerts, &alert_to_elevator_closure/1), + other_stations_with_closures: + format_outside_closures(outside_alerts, parent_station_map, routes_map) + } + ] + else + :error -> + [] + + {:error, error} -> + Logger.error("[elevator_status_instances] #{inspect(error)}") + [] + end + end + + defp relevant_alert?(alert) do + relevant_effect?(alert) and informs_one_facility?(alert) + end + + defp relevant_effect?(alert), do: alert.effect == :elevator_closure + + defp informs_one_facility?(%Alert{informed_entities: informed_entities}) do + Enum.all?(informed_entities, &match?(%{facility: _}, &1)) and + informed_entities |> Enum.map(& &1.facility) |> Enum.uniq() |> Enum.count() == 1 + 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(closures) do + closures + |> Enum.flat_map(fn %Alert{informed_entities: informed_entities} -> + informed_entities + |> Enum.filter(&InformedEntity.parent_station?/1) + |> Enum.map(fn %{stop: stop_id} -> stop_id end) + 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(&Route.icon/1) + |> Enum.uniq() + end + + defp split_closures_by_location(closures, home_stop_id) do + Enum.split_with(closures, fn %Alert{informed_entities: informed_entities} -> + home_stop_id in Enum.map(informed_entities, & &1.stop) + end) + end + + defp alert_to_elevator_closure(%Alert{ + id: id, + informed_entities: entities, + description: description, + header: header + }) do + facility = Enum.find_value(entities, fn %{facility: facility} -> facility end) + + %ElevatorClosures.Closure{ + id: id, + elevator_name: facility.name, + elevator_id: facility.id, + description: description, + header_text: header + } + end + + defp format_outside_closures(closures, station_id_to_name, station_id_to_routes) do + closures + |> Enum.group_by(&get_parent_station_id_from_informed_entities(&1.informed_entities)) + |> Enum.map(fn {parent_station_id, closures} -> + closures_at_station = Enum.map(closures, &alert_to_elevator_closure/1) + + route_pills = + station_id_to_routes + |> Map.fetch!(parent_station_id) + |> Enum.map(&RoutePill.serialize_icon/1) + + %ElevatorClosures.Station{ + id: parent_station_id, + name: Map.fetch!(station_id_to_name, parent_station_id), + route_icons: route_pills, + closures: closures_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 d4fe5967e..5d429e1d6 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..a819ca248 100644 --- a/lib/screens/v2/widget_instance/elevator_closures.ex +++ b/lib/screens/v2/widget_instance/elevator_closures.ex @@ -1,22 +1,61 @@ 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_closures other_stations_with_closures]a @type t :: %__MODULE__{ - screen: Screen.t(), - alerts: list(Alert.t()) + id: String.t(), + in_station_closures: list(__MODULE__.Closure.t()), + other_stations_with_closures: 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.Routes.Route + alias Screens.V2.WidgetInstance.ElevatorClosures.Closure + + @derive Jason.Encoder + + defstruct ~w[id name route_icons closures]a + + @type t :: %__MODULE__{ + id: Stop.id(), + name: String.t(), + route_icons: list(Route.icon()), + closures: list(Closure.t()) + } + end + + defmodule Closure 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_closures: in_station_closures, + other_stations_with_closures: other_stations_with_closures + }), + do: %{ + id: id, + in_station_closures: in_station_closures, + other_stations_with_closures: other_stations_with_closures + } + 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..78521155b --- /dev/null +++ b/test/screens/v2/candidate_generator/elevator/closures_test.exs @@ -0,0 +1,233 @@ +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.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/1" do + test "Only returns alerts with effect of :elevator_closure" do + expect(MockFacility, :fetch_stop_for_facility, fn "111" -> + {:ok, %Stop{id: "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_closures: [ + %{ + id: "1", + description: nil, + elevator_name: "Test", + elevator_id: "facility-test", + header_text: nil + } + ], + other_stations_with_closures: [] + } + ] = + ElevatorClosures.elevator_status_instances( + struct(Screen, app_id: :elevator_v2, app_params: %Elevator{elevator_id: "111"}) + ) + end + + test "Groups outside closures by station" do + expect(MockFacility, :fetch_stop_for_facility, fn "111" -> + {:ok, %Stop{id: "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_closures: [], + other_stations_with_closures: [ + %{ + id: "place-haecl", + name: "Haymarket", + route_icons: [%{type: :text, text: "OL", color: :orange}], + closures: [ + %{ + 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"}) + ) + end + + test "Filters alerts with no facilities or more than one facility" do + expect(MockFacility, :fetch_stop_for_facility, fn "111" -> + {:ok, %Stop{id: "place-test"}} + end) + + expect(MockStop, :fetch_parent_station_name_map, fn -> + {:ok, %{"place-haecl" => "Haymarket"}} + end) + + expect(MockRoute, :fetch, fn _ -> {: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"} + ] + ), + struct(Alert, + id: "2", + effect: :elevator_closure, + informed_entities: [ + %{stop: "place-haecl", facility: %{name: "Test 2", id: "facility-test-2"}}, + %{stop: "place-haecl", facility: %{name: "Test 2", id: "facility-test-3"}} + ] + ) + ] + + {:ok, alerts} + end) + + [ + %Screens.V2.WidgetInstance.ElevatorClosures{ + id: "111", + in_station_closures: [], + other_stations_with_closures: [] + } + ] = + ElevatorClosures.elevator_status_instances( + struct(Screen, app_id: :elevator_v2, app_params: %Elevator{elevator_id: "111"}) + ) + end + + test "Return empty routes on API error" do + expect(MockFacility, :fetch_stop_for_facility, fn "111" -> + {:ok, %Stop{id: "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_closures: [ + %{ + id: "1", + description: nil, + elevator_name: "Test", + elevator_id: "facility-test", + header_text: nil + } + ], + other_stations_with_closures: [] + } + ] = + ElevatorClosures.elevator_status_instances( + struct(Screen, app_id: :elevator_v2, app_params: %Elevator{elevator_id: "111"}) + ) + 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..553ea625a 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_closures: [ + %ElevatorClosures.Closure{ + description: "Test Alert Description", + elevator_name: "Test Elevator", + elevator_id: "111", + id: "1", + header_text: "Test Alert Header" + } + ], + other_stations_with_closures: [ + %ElevatorClosures.Station{ + name: "Forest Hills", + route_icons: ["Orange"], + closures: [ + %ElevatorClosures.Closure{ + description: "FH Alert Description", + elevator_name: "FH Elevator", + elevator_id: "222", + id: "2", + header_text: "FH Alert Header" + } + ] + } + ] } } end @@ -22,12 +43,8 @@ defmodule Screens.V2.WidgetInstance.ElevatorClosuresTest do end 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) + test "returns map with id and closures", %{instance: instance} do + assert Map.from_struct(instance) == WidgetInstance.serialize(instance) end end diff --git a/test/support/mocks.ex b/test/support/mocks.ex index 7239c3264..aacb6d9f0 100644 --- a/test/support/mocks.ex +++ b/test/support/mocks.ex @@ -4,3 +4,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)