diff --git a/config/test.exs b/config/test.exs index a301b550a..21faa85b7 100644 --- a/config/test.exs +++ b/config/test.exs @@ -165,6 +165,11 @@ config :screens, Screens.V2.ScreenData, config :screens, Screens.V2.CandidateGenerator.DupNew, stop_module: Screens.Stops.MockStop +config :screens, Screens.V2.RDS, + departure_module: Screens.V2.MockDeparture, + route_pattern_module: Screens.RoutePatterns.MockRoutePattern, + stop_module: Screens.Stops.MockStop + config :screens, Screens.LastTrip, trip_updates_adapter: Screens.LastTrip.TripUpdates.Noop, vehicle_positions_adapter: Screens.LastTrip.VehiclePositions.Noop diff --git a/lib/screens/lines/line.ex b/lib/screens/lines/line.ex index 1247e2e89..3fb6ec600 100644 --- a/lib/screens/lines/line.ex +++ b/lib/screens/lines/line.ex @@ -1,8 +1,7 @@ defmodule Screens.Lines.Line do @moduledoc false - @enforce_keys ~w[id long_name short_name sort_order]a - defstruct @enforce_keys + defstruct ~w[id long_name short_name sort_order]a @type id :: String.t() diff --git a/lib/screens/route_patterns/route_direction_stops.ex b/lib/screens/route_patterns/route_direction_stops.ex new file mode 100644 index 000000000..0e90a42ad --- /dev/null +++ b/lib/screens/route_patterns/route_direction_stops.ex @@ -0,0 +1,82 @@ +defmodule Screens.RoutePatterns.RouteDirectionStops do + @moduledoc false + + require Logger + + def parse_result(%{"data" => data, "included" => included}, route_id) do + included_data = parse_included_data(included) + parse_data(data, included_data, route_id) + end + + def parse_result(_, _) do + Logger.warning("Unrecognized format of route_pattern data.") + :error + end + + defp parse_included_data(data) do + data + |> Enum.map(fn item -> + {{Map.get(item, "type"), Map.get(item, "id")}, parse_included(item)} + end) + |> Enum.into(%{}) + end + + defp parse_included(%{"type" => "stop"} = item) do + Screens.Stops.Parser.parse_stop(item) + end + + defp parse_included(%{ + "type" => "trip", + "relationships" => %{"stops" => %{"data" => stops_data}} + }) do + Enum.map(stops_data, &parse_stop_data/1) + end + + defp parse_stop_data(%{"id" => stop_id, "type" => "stop"}) do + stop_id + end + + defp parse_data(data, included_data, route_id) do + filtered_data = filter_by_route(data, route_id) + [typical_data | _] = filtered_data + parse_route_pattern(typical_data, included_data) + end + + defp filter_by_route(data, route_id) do + Enum.filter(data, &has_related_route(&1, route_id)) + end + + defp has_related_route(%{"relationships" => %{"route" => %{"data" => %{"id" => id}}}}, route_id) do + route_id == id + end + + defp has_related_route(_, _route_id) do + false + end + + defp parse_route_pattern( + %{ + "relationships" => %{ + "representative_trip" => %{"data" => %{"id" => trip_id, "type" => "trip"}} + } + }, + included_data + ) do + # The only way this function output an empty array is if the trip data has an empty stop list + # This happens occasionally in dev-green + parsed = + included_data + |> Map.get({"trip", trip_id}) + |> Enum.map(fn stop_id -> Map.get(included_data, {"stop", stop_id}) end) + + case parsed do + # If `trip` is present, but the stop array is empty, there's a problem with the trip in the API + [] -> + Logger.warning("Trip data doesn't contain stop ids. trip_id: #{trip_id}") + :error + + _ -> + parsed + end + end +end diff --git a/lib/screens/stops/parser.ex b/lib/screens/stops/parser.ex index 0821ce17a..b0e193b9f 100644 --- a/lib/screens/stops/parser.ex +++ b/lib/screens/stops/parser.ex @@ -5,6 +5,7 @@ defmodule Screens.Stops.Parser do "id" => id, "attributes" => %{ "name" => name, + "location_type" => location_type, "platform_code" => platform_code, "platform_name" => platform_name } @@ -12,6 +13,7 @@ defmodule Screens.Stops.Parser do %Screens.Stops.Stop{ id: id, name: name, + location_type: location_type, platform_code: platform_code, platform_name: platform_name } diff --git a/lib/screens/stops/stop.ex b/lib/screens/stops/stop.ex index 4fa64c33b..c935a130a 100644 --- a/lib/screens/stops/stop.ex +++ b/lib/screens/stops/stop.ex @@ -19,16 +19,14 @@ defmodule Screens.Stops.Stop do alias Screens.V3Api alias ScreensConfig.V2.{BusEink, BusShelter, Dup, GlEink, PreFare} - defstruct id: nil, - name: nil, - platform_code: nil, - platform_name: nil + defstruct ~w[id name location_type platform_code platform_name]a @type id :: String.t() @type t :: %__MODULE__{ id: id, name: String.t(), + location_type: 0 | 1 | 2 | 3, platform_code: String.t() | nil, platform_name: String.t() | nil } @@ -311,6 +309,50 @@ defmodule Screens.Stops.Stop do end end + @doc """ + Returns a list of child stops for each given stop ID (in the same order). For stop IDs that are + already child stops, the list contains only the stop itself. For stop IDs that do not exist, the + list is empty. + """ + @callback fetch_child_stops([id()]) :: {:ok, [[t()]]} | {:error, term()} + def fetch_child_stops(stop_ids, get_json_fn \\ &Screens.V3Api.get_json/2) do + case get_json_fn.("stops", %{ + "filter[id]" => Enum.join(stop_ids, ","), + "include" => "child_stops" + }) do + {:ok, %{"data" => data} = response} -> + child_stops = + response + |> Map.get("included", []) + |> Enum.map(&Stops.Parser.parse_stop/1) + |> Map.new(&{&1.id, &1}) + + stops_with_children = + data + |> Enum.map(fn %{"relationships" => %{"child_stops" => %{"data" => children}}} = stop -> + { + Stops.Parser.parse_stop(stop), + children + |> Enum.map(fn %{"id" => id} -> Map.fetch!(child_stops, id) end) + |> Enum.filter(&(&1.location_type == 0)) + } + end) + |> Map.new(&{elem(&1, 0).id, &1}) + + {:ok, + Enum.map(stop_ids, fn stop_id -> + case stops_with_children[stop_id] do + nil -> [] + {stop, []} -> [stop] + {_stop, children} -> children + end + end)} + + error -> + {:error, error} + end + end + # --- END API functions --- def stop_on_route?(stop_id, stop_sequence) when not is_nil(stop_id) do diff --git a/lib/screens/v2/departure.ex b/lib/screens/v2/departure.ex index 60765248e..fa597cce9 100644 --- a/lib/screens/v2/departure.ex +++ b/lib/screens/v2/departure.ex @@ -32,7 +32,7 @@ defmodule Screens.V2.Departure do @type fetch :: (params(), opts() -> result()) - @spec fetch(params(), opts()) :: result() + @callback fetch(params(), opts()) :: result() def fetch(params, opts \\ []) do # This is equivalent to an argument with a default value, so it's fine # credo:disable-for-next-line Screens.Checks.UntestableDateTime diff --git a/lib/screens/v2/rds.ex b/lib/screens/v2/rds.ex new file mode 100644 index 000000000..8ff2048cc --- /dev/null +++ b/lib/screens/v2/rds.ex @@ -0,0 +1,107 @@ +defmodule Screens.V2.RDS do + @moduledoc """ + Real-time Destination State. Represents a "destination" a rider could reach (ordinarily or + currently) by taking a line from a stop, and the "state" of that destination, in the form of + departure countdowns, a headway estimate, a message that service has ended for the day, etc. + + Conceptually, screen configuration is translated into a set of "destinations", each of which is + assigned a "state", containing all the data required to present it. These can then be translated + into widgets by screen-specific code. + """ + + alias Screens.Lines.Line + alias Screens.RoutePatterns.RoutePattern + alias Screens.Routes.Route + alias Screens.Stops.Stop + alias Screens.V2.Departure + + alias ScreensConfig.V2.Departures + alias ScreensConfig.V2.Departures.{Query, Section} + + alias __MODULE__.NoDepartures + + @type t :: %__MODULE__{ + stop: Stop.t(), + line: Line.t(), + headsign: String.t(), + state: NoDepartures.t() + } + @enforce_keys ~w[stop line headsign state]a + defstruct @enforce_keys + + defmodule NoDepartures do + @moduledoc """ + The fallback state, presented as a headway or "no departures" message. A destination is in + this state when A) displaying departures has been manually disabled for the relevant transit + mode, or B) there simply aren't any upcoming departures we want to display, and as far as we + can tell this is "normal"/expected. + """ + @type t :: %__MODULE__{} + defstruct [] + end + + @departure Application.compile_env(:screens, [__MODULE__, :departure_module], Departure) + + @route_pattern Application.compile_env( + :screens, + [__MODULE__, :route_pattern_module], + RoutePattern + ) + + @stop Application.compile_env(:screens, [__MODULE__, :stop_module], Stop) + + @max_departure_minutes 90 + + @doc """ + Generates destinations from departures widget configuration. + + Produces a list of destinations for each configured `Section`, in the same order the sections + occur in the config. + + ⚠️ Enforces that every section's query contains at least one ID in `stop_ids`. + """ + @spec get(Departures.t()) :: [[t()]] + @spec get(Departures.t(), DateTime.t()) :: [[t()]] + def get(%Departures{sections: sections}, now \\ DateTime.utc_now()), + do: Enum.map(sections, &from_section(&1, now)) + + defp from_section( + %Section{query: %Query{params: %Query.Params{stop_ids: stop_ids} = params}}, + now + ) + when stop_ids != [] do + {:ok, child_stops} = @stop.fetch_child_stops(stop_ids) + {:ok, typical_patterns} = params |> Map.put(:typicality, 1) |> @route_pattern.fetch() + {:ok, departures} = @departure.fetch(params, include_schedules: true, now: now) + + (tuples_from_departures(departures, now) ++ + tuples_from_patterns(typical_patterns, child_stops)) + |> Enum.uniq() + |> Enum.map(fn {stop, line, headsign} -> + %__MODULE__{stop: stop, line: line, headsign: headsign, state: %NoDepartures{}} + end) + end + + defp tuples_from_departures(departures, now) do + departures + |> Enum.reject(fn d -> + DateTime.diff(Departure.time(d), now, :minute) > @max_departure_minutes + end) + |> Enum.map(fn d -> + {Departure.stop(d), Departure.route(d).line, Departure.representative_headsign(d)} + end) + end + + defp tuples_from_patterns(route_patterns, child_stops) do + stop_ids = child_stops |> List.flatten() |> Enum.map(& &1.id) |> MapSet.new() + + Enum.flat_map( + route_patterns, + fn %RoutePattern{headsign: headsign, route: %Route{line: line}, stops: stops} -> + stops + |> Enum.filter(&(&1.id in stop_ids)) + |> Enum.map(fn stop -> {stop, line, headsign} end) + end + ) + end +end diff --git a/test/screens/stops/stop_test.exs b/test/screens/stops/stop_test.exs new file mode 100644 index 000000000..367bcdec3 --- /dev/null +++ b/test/screens/stops/stop_test.exs @@ -0,0 +1,55 @@ +defmodule Screens.Stops.StopTest do + use ExUnit.Case, async: true + + alias Screens.Stops.Stop + + describe "fetch_child_stops/2" do + test "fetches the child stops of the provided stop IDs" do + stop_attributes = %{ + "name" => "test", + "location_type" => 0, + "platform_code" => "", + "platform_name" => "" + } + + get_json_fn = + fn "stops", %{"filter[id]" => "sX,s1,p1,p2", "include" => "child_stops"} -> + { + :ok, + %{ + "data" => [ + # suppose sX doesn't exist + %{ + "id" => "s1", + "attributes" => stop_attributes, + "relationships" => %{"child_stops" => %{"data" => []}} + }, + %{ + "id" => "p1", + "attributes" => Map.put(stop_attributes, "location_type", 1), + "relationships" => %{ + "child_stops" => %{"data" => [%{"id" => "c1"}, %{"id" => "c2"}]} + } + }, + %{ + "id" => "p2", + "attributes" => Map.put(stop_attributes, "location_type", 1), + "relationships" => %{ + "child_stops" => %{"data" => [%{"id" => "c3"}]} + } + } + ], + "included" => [ + %{"id" => "c1", "attributes" => stop_attributes}, + %{"id" => "c2", "attributes" => stop_attributes}, + %{"id" => "c3", "attributes" => stop_attributes} + ] + } + } + end + + assert {:ok, [[], [%Stop{id: "s1"}], [%Stop{id: "c1"}, %Stop{id: "c2"}], [%Stop{id: "c3"}]]} = + Stop.fetch_child_stops(~w[sX s1 p1 p2], get_json_fn) + end + end +end diff --git a/test/screens/v2/rds_test.exs b/test/screens/v2/rds_test.exs new file mode 100644 index 000000000..ba4e10b57 --- /dev/null +++ b/test/screens/v2/rds_test.exs @@ -0,0 +1,142 @@ +defmodule Screens.V2.RDSTest do + use ExUnit.Case, async: true + + alias Screens.Lines.Line + alias Screens.Predictions.Prediction + alias Screens.RoutePatterns.{MockRoutePattern, RoutePattern} + alias Screens.Routes.Route + alias Screens.Schedules.Schedule + alias Screens.Stops.{MockStop, Stop} + alias Screens.Trips.Trip + alias Screens.V2.{Departure, MockDeparture} + alias Screens.V2.RDS + alias ScreensConfig.V2.Departures + alias ScreensConfig.V2.Departures.{Query, Section} + + import Mox + setup :verify_on_exit! + + describe "get/1" do + setup do + stub(MockDeparture, :fetch, fn _, _ -> {:ok, []} end) + stub(MockRoutePattern, :fetch, fn _ -> {:ok, []} end) + stub(MockStop, :fetch_child_stops, fn ids -> {:ok, Enum.map(ids, &[%Stop{id: &1}])} end) + :ok + end + + defp no_departures(stop_id, line_id, headsign) do + %RDS{ + stop: %Stop{id: stop_id}, + line: %Line{id: line_id}, + headsign: headsign, + state: %RDS.NoDepartures{} + } + end + + test "creates destinations from typical route patterns" do + stop_ids = ~w[s0 s1] + + expect(MockStop, :fetch_child_stops, fn ^stop_ids -> + {:ok, [[%Stop{id: "sA"}, %Stop{id: "sB"}], [%Stop{id: "sC"}]]} + end) + + expect(MockRoutePattern, :fetch, fn %{route_type: :bus, stop_ids: ^stop_ids, typicality: 1} -> + {:ok, + [ + %RoutePattern{ + id: "A", + headsign: "hA", + route: %Route{id: "r1", line: %Line{id: "l1"}}, + stops: [%Stop{id: "sA"}, %Stop{id: "otherX"}] + }, + %RoutePattern{ + id: "B", + headsign: "hB", + route: %Route{id: "r2", line: %Line{id: "l2"}}, + stops: [%Stop{id: "otherY"}, %Stop{id: "sB"}] + }, + %RoutePattern{ + id: "C", + headsign: "hC", + route: %Route{id: "r2", line: %Line{id: "l2"}}, + stops: [%Stop{id: "sC"}, %Stop{id: "otherZ"}] + } + ]} + end) + + departures = %Departures{ + sections: [ + %Section{query: %Query{params: %Query.Params{route_type: :bus, stop_ids: stop_ids}}} + ] + } + + assert RDS.get(departures) == [ + [ + no_departures("sA", "l1", "hA"), + no_departures("sB", "l2", "hB"), + no_departures("sC", "l2", "hC") + ] + ] + end + + test "creates destinations from upcoming predicted and scheduled departures" do + now = ~U[2024-10-11 12:00:00Z] + + expect(MockDeparture, :fetch, fn + %{direction_id: 0, route_type: :bus, stop_ids: ["s0"]}, + [include_schedules: true, now: ^now] -> + { + :ok, + [ + %Departure{ + prediction: %Prediction{ + departure_time: ~U[2024-10-11 12:30:00Z], + route: %Route{id: "r1", line: %Line{id: "l1"}}, + stop: %Stop{id: "s1"}, + trip: %Trip{headsign: "other1", pattern_headsign: "h1"} + }, + schedule: nil + }, + %Departure{ + prediction: nil, + schedule: %Schedule{ + departure_time: ~U[2024-10-11 13:15:00Z], + route: %Route{id: "r2", line: %Line{id: "l2"}}, + stop: %Stop{id: "s2"}, + trip: %Trip{headsign: "other2", pattern_headsign: "h2"} + } + }, + %Departure{ + prediction: %Prediction{ + # further in the future than the cutoff + departure_time: ~U[2024-10-11 14:00:00Z], + route: %Route{id: "r3", line: %Line{id: "l3"}}, + stop: %Stop{id: "s3"}, + trip: %Trip{headsign: "other3", pattern_headsign: "h3"} + }, + schedule: nil + } + ] + } + end) + + departures = %Departures{ + sections: [ + %Section{ + query: %Query{ + params: %Query.Params{ + direction_id: 0, + route_type: :bus, + stop_ids: ["s0"] + } + } + } + ] + } + + assert RDS.get(departures, now) == [ + [no_departures("s1", "l1", "h1"), no_departures("s2", "l2", "h2")] + ] + end + end +end diff --git a/test/support/mocks.ex b/test/support/mocks.ex index 145ae0ebd..d035ff3b4 100644 --- a/test/support/mocks.ex +++ b/test/support/mocks.ex @@ -1,3 +1,5 @@ Mox.defmock(Screens.Config.MockCache, for: Screens.Config.Cache) +Mox.defmock(Screens.RoutePatterns.MockRoutePattern, for: Screens.RoutePatterns.RoutePattern) 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)