diff --git a/config/config.exs b/config/config.exs index cd26b4f6..ebb982cd 100644 --- a/config/config.exs +++ b/config/config.exs @@ -51,6 +51,8 @@ config :logger, :console, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +config :mobile_app_backend, timezone: "America/New_York" + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/lib/open_trip_planner_client.ex b/lib/open_trip_planner_client.ex new file mode 100644 index 00000000..5e5fd5b3 --- /dev/null +++ b/lib/open_trip_planner_client.ex @@ -0,0 +1,207 @@ +defmodule OpenTripPlannerClient do + @moduledoc """ + Fetches data from the OpenTripPlanner API. + + ## Configuration + + ```elixir + config :mobile_app_backend, + otp_url: "http://localhost:8080", + timezone: "America/New_York" + ``` + """ + + require Logger + + alias OpenTripPlannerClient.{Itinerary, ItineraryTag, NamedPosition, ParamsBuilder, Parser} + + @behaviour OpenTripPlannerClient.Behaviour + + @type error :: OpenTripPlannerClient.Behaviour.error() + @type plan_opt :: OpenTripPlannerClient.Behaviour.plan_opt() + + @impl true + @doc """ + Generate a trip plan with the given endpoints and options. + """ + @spec plan(NamedPosition.t(), NamedPosition.t(), [plan_opt()]) :: + {:ok, Itinerary.t()} | {:error, error()} + def plan(from, to, opts) do + accessible? = Keyword.get(opts, :wheelchair_accessible?, false) + + {postprocess_opts, opts} = Keyword.split(opts, [:tags]) + + with {:ok, params} <- ParamsBuilder.build_params(from, to, opts) do + param_string = Enum.map_join(params, "\n", fn {key, val} -> ~s{#{key}: #{val}} end) + + graphql_query = """ + { + plan( + #{param_string} + ) + #{itinerary_shape()} + } + """ + + root_url = + Keyword.get(opts, :root_url, Application.fetch_env!(:mobile_app_backend, :otp_url)) + + graphql_url = "#{root_url}/otp/routers/default/index/" + + with {:ok, body} <- send_request(graphql_url, graphql_query), + {:ok, itineraries} <- Parser.parse_ql(body, accessible?) do + tags = Keyword.get(postprocess_opts, :tags, []) + + result = + Enum.reduce(tags, itineraries, fn tag, itineraries -> + ItineraryTag.apply_tag(tag, itineraries) + end) + + {:ok, result} + end + end + end + + defp send_request(url, query) do + with {:ok, response} <- log_response(url, query), + %{status: 200, body: body} <- response do + {:ok, body} + else + %{status: _} = response -> + {:error, response} + + error -> + error + end + end + + defp log_response(url, query) do + graphql_req = + Req.new(base_url: url) + |> AbsintheClient.attach() + + {duration, response} = + :timer.tc( + Req, + :post, + [graphql_req, [graphql: query]] + ) + + _ = + Logger.info(fn -> + "#{__MODULE__}.plan_response url=#{url} query=#{inspect(query)} #{status_text(response)} duration=#{duration / :timer.seconds(1)}" + end) + + response + end + + defp status_text({:ok, %{status: code}}) do + "status=#{code}" + end + + defp status_text({:error, error}) do + "status=error error=#{inspect(error)}" + end + + defp itinerary_shape do + """ + { + routingErrors { + code + description + } + itineraries { + accessibilityScore + startTime + endTime + duration + legs { + mode + startTime + endTime + distance + duration + intermediateStops { + id + gtfsId + name + desc + lat + lon + code + locationType + } + transitLeg + headsign + realTime + realtimeState + agency { + id + gtfsId + name + } + alerts { + id + alertHeaderText + alertDescriptionText + } + fareProducts { + id + product { + id + name + riderCategory { + id + name + + } + } + } + from { + name + lat + lon + departureTime + arrivalTime + stop { + gtfsId + } + } + to { + name + lat + lon + departureTime + arrivalTime + stop { + gtfsId + } + } + route { + gtfsId + longName + shortName + desc + color + textColor + } + trip { + gtfsId + } + steps { + distance + streetName + lat + lon + relativeDirection + stayOn + } + legGeometry { + points + } + } + } + } + """ + end +end diff --git a/lib/open_trip_planner_client/behaviour.ex b/lib/open_trip_planner_client/behaviour.ex new file mode 100644 index 00000000..92a23942 --- /dev/null +++ b/lib/open_trip_planner_client/behaviour.ex @@ -0,0 +1,28 @@ +defmodule OpenTripPlannerClient.Behaviour do + @moduledoc """ + A behaviour that specifies the API for the `OpenTripPlannerClient`. + + May be useful for testing with libraries like [Mox](https://hex.pm/packages/mox). + """ + + alias OpenTripPlannerClient.{Itinerary, ItineraryTag, NamedPosition} + + @type plan_opt :: + {:arrive_by, DateTime.t()} + | {:depart_at, DateTime.t()} + | {:wheelchair_accessible?, boolean} + | {:optimize_for, :less_walking | :fewest_transfers} + | {:tags, [ItineraryTag.t()]} + + @type error :: + :outside_bounds + | :timeout + | :no_transit_times + | :too_close + | :location_not_accessible + | :path_not_found + | :unknown + + @callback plan(from :: NamedPosition.t(), to :: NamedPosition.t(), opts :: [plan_opt()]) :: + {:ok, Itinerary.t()} | {:error, error()} +end diff --git a/lib/open_trip_planner_client/itinerary.ex b/lib/open_trip_planner_client/itinerary.ex new file mode 100644 index 00000000..a5cca749 --- /dev/null +++ b/lib/open_trip_planner_client/itinerary.ex @@ -0,0 +1,75 @@ +defmodule OpenTripPlannerClient.Itinerary do + @moduledoc """ + A trip at a particular time. + + An Itinerary is a single trip, with the legs being the different types of + travel. Itineraries are separate even if they use the same modes but happen + at different times of day. + """ + + alias OpenTripPlannerClient.Leg + + @enforce_keys [:start, :stop] + defstruct [ + :start, + :stop, + legs: [], + accessible?: false, + tags: MapSet.new() + ] + + @type t :: %__MODULE__{ + start: DateTime.t(), + stop: DateTime.t(), + legs: [Leg.t()], + accessible?: boolean, + tags: MapSet.t(atom()) + } + + @doc "Gets the time in seconds between the start and stop of the itinerary." + @spec duration(t()) :: integer() + def duration(%__MODULE__{start: start, stop: stop}) do + DateTime.diff(stop, start, :second) + end + + @doc "Total walking distance over all legs, in meters" + @spec walking_distance(t) :: float + def walking_distance(itinerary) do + itinerary + |> Enum.map(&Leg.walking_distance/1) + |> Enum.sum() + end + + @doc "Determines if two itineraries represent the same sequence of legs at the same time" + @spec same_itinerary?(t, t) :: boolean + def same_itinerary?(itinerary_1, itinerary_2) do + itinerary_1.start == itinerary_2.start && itinerary_1.stop == itinerary_2.stop && + same_legs?(itinerary_2, itinerary_2) + end + + @spec same_legs?(t, t) :: boolean + defp same_legs?(%__MODULE__{legs: legs_1}, %__MODULE__{legs: legs_2}) do + Enum.count(legs_1) == Enum.count(legs_2) && + legs_1 |> Enum.zip(legs_2) |> Enum.all?(fn {l1, l2} -> Leg.same_leg?(l1, l2) end) + end + + defimpl Enumerable do + alias OpenTripPlannerClient.Leg + + def count(%@for{legs: legs}) do + Enumerable.count(legs) + end + + def member?(%@for{legs: legs}, element) do + Enumerable.member?(legs, element) + end + + def reduce(%@for{legs: legs}, acc, fun) do + Enumerable.reduce(legs, acc, fun) + end + + def slice(%@for{legs: legs}) do + Enumerable.slice(legs) + end + end +end diff --git a/lib/open_trip_planner_client/itinerary_tag.ex b/lib/open_trip_planner_client/itinerary_tag.ex new file mode 100644 index 00000000..e6348a65 --- /dev/null +++ b/lib/open_trip_planner_client/itinerary_tag.ex @@ -0,0 +1,39 @@ +defmodule OpenTripPlannerClient.ItineraryTag do + @moduledoc """ + Logic for a tag which can be applied to itineraries which are the best by some criterion. + """ + alias OpenTripPlannerClient.Itinerary + + @callback optimal :: :max | :min + @callback score(Itinerary.t()) :: number() | nil + @callback tag :: atom() + + @type t :: module() + + @doc """ + Applies the tag defined by the given module to the itinerary with the optimal score. + + If multiple itineraries are optimal, they will each get the tag. + If all itineraries have a score of nil, nothing gets the tag. + """ + @spec apply_tag(t(), [Itinerary.t()]) :: [Itinerary.t()] + def apply_tag(tag_module, itineraries) do + scores = itineraries |> Enum.map(&tag_module.score/1) + {min_score, max_score} = Enum.min_max(scores |> Enum.reject(&is_nil/1), fn -> {nil, nil} end) + + best_score = + case tag_module.optimal() do + :max -> max_score + :min -> min_score + end + + Enum.zip(itineraries, scores) + |> Enum.map(fn {itinerary, score} -> + if not is_nil(score) and score == best_score do + update_in(itinerary.tags, &MapSet.put(&1, tag_module.tag())) + else + itinerary + end + end) + end +end diff --git a/lib/open_trip_planner_client/itinerary_tag/earliest_arrival.ex b/lib/open_trip_planner_client/itinerary_tag/earliest_arrival.ex new file mode 100644 index 00000000..269a803f --- /dev/null +++ b/lib/open_trip_planner_client/itinerary_tag/earliest_arrival.ex @@ -0,0 +1,17 @@ +defmodule OpenTripPlannerClient.ItineraryTag.EarliestArrival do + @moduledoc false + @behaviour OpenTripPlannerClient.ItineraryTag + + alias OpenTripPlannerClient.Itinerary + + @impl OpenTripPlannerClient.ItineraryTag + def optimal, do: :min + + @impl OpenTripPlannerClient.ItineraryTag + def score(%Itinerary{} = itinerary) do + itinerary.stop |> DateTime.to_unix() + end + + @impl OpenTripPlannerClient.ItineraryTag + def tag, do: :earliest_arrival +end diff --git a/lib/open_trip_planner_client/itinerary_tag/least_walking.ex b/lib/open_trip_planner_client/itinerary_tag/least_walking.ex new file mode 100644 index 00000000..fab5018d --- /dev/null +++ b/lib/open_trip_planner_client/itinerary_tag/least_walking.ex @@ -0,0 +1,17 @@ +defmodule OpenTripPlannerClient.ItineraryTag.LeastWalking do + @moduledoc false + @behaviour OpenTripPlannerClient.ItineraryTag + + alias OpenTripPlannerClient.Itinerary + + @impl OpenTripPlannerClient.ItineraryTag + def optimal, do: :min + + @impl OpenTripPlannerClient.ItineraryTag + def score(%Itinerary{} = itinerary) do + Itinerary.walking_distance(itinerary) + end + + @impl OpenTripPlannerClient.ItineraryTag + def tag, do: :least_walking +end diff --git a/lib/open_trip_planner_client/itinerary_tag/shortest_trip.ex b/lib/open_trip_planner_client/itinerary_tag/shortest_trip.ex new file mode 100644 index 00000000..2725131f --- /dev/null +++ b/lib/open_trip_planner_client/itinerary_tag/shortest_trip.ex @@ -0,0 +1,17 @@ +defmodule OpenTripPlannerClient.ItineraryTag.ShortestTrip do + @moduledoc false + @behaviour OpenTripPlannerClient.ItineraryTag + + alias OpenTripPlannerClient.Itinerary + + @impl OpenTripPlannerClient.ItineraryTag + def optimal, do: :min + + @impl OpenTripPlannerClient.ItineraryTag + def score(%Itinerary{} = itinerary) do + Itinerary.duration(itinerary) + end + + @impl OpenTripPlannerClient.ItineraryTag + def tag, do: :shortest_trip +end diff --git a/lib/open_trip_planner_client/leg.ex b/lib/open_trip_planner_client/leg.ex new file mode 100644 index 00000000..aff50d43 --- /dev/null +++ b/lib/open_trip_planner_client/leg.ex @@ -0,0 +1,46 @@ +defmodule OpenTripPlannerClient.Leg do + @moduledoc """ + A single-mode part of an Itinerary + + An Itinerary can take multiple modes of transportation (walk, bus, + train, &c). Leg represents a single mode of travel during journey. + """ + alias OpenTripPlannerClient.{NamedPosition, PersonalDetail, TransitDetail} + + defstruct start: DateTime.from_unix!(-1), + stop: DateTime.from_unix!(0), + mode: nil, + from: nil, + to: nil, + name: nil, + long_name: nil, + type: nil, + description: nil, + url: nil, + polyline: "" + + @type mode :: PersonalDetail.t() | TransitDetail.t() + @type t :: %__MODULE__{ + start: DateTime.t(), + stop: DateTime.t(), + mode: mode, + from: NamedPosition.t() | nil, + to: NamedPosition.t(), + name: String.t(), + long_name: String.t(), + type: String.t(), + description: String.t(), + url: String.t(), + polyline: String.t() + } + + @spec walking_distance(t) :: float + def walking_distance(%__MODULE__{mode: %PersonalDetail{distance: nil}}), do: 0.0 + def walking_distance(%__MODULE__{mode: %PersonalDetail{distance: distance}}), do: distance + def walking_distance(%__MODULE__{mode: %TransitDetail{}}), do: 0.0 + + @doc "Determines if two legs have the same to and from fields" + @spec same_leg?(t, t) :: boolean + def same_leg?(%__MODULE__{from: from, to: to}, %__MODULE__{from: from, to: to}), do: true + def same_leg?(_leg_1, _leg_2), do: false +end diff --git a/lib/open_trip_planner_client/named_position.ex b/lib/open_trip_planner_client/named_position.ex new file mode 100644 index 00000000..23b696b1 --- /dev/null +++ b/lib/open_trip_planner_client/named_position.ex @@ -0,0 +1,15 @@ +defmodule OpenTripPlannerClient.NamedPosition do + @moduledoc "Defines a position for a trip plan as a stop and/or lat/lon" + + defstruct name: "", + stop_id: nil, + latitude: nil, + longitude: nil + + @type t :: %__MODULE__{ + name: String.t(), + stop_id: String.t() | nil, + latitude: float | nil, + longitude: float | nil + } +end diff --git a/lib/open_trip_planner_client/params_builder.ex b/lib/open_trip_planner_client/params_builder.ex new file mode 100644 index 00000000..f5dd4230 --- /dev/null +++ b/lib/open_trip_planner_client/params_builder.ex @@ -0,0 +1,95 @@ +defmodule OpenTripPlannerClient.ParamsBuilder do + @moduledoc false + alias OpenTripPlannerClient.NamedPosition + + @doc "Convert general planning options into query params for OTP" + @spec build_params(NamedPosition.t(), NamedPosition.t(), [OpenTripPlannerClient.plan_opt()]) :: + {:ok, %{String.t() => String.t()}} | {:error, any} + def build_params(from, to, opts) do + from_string = location(from) + to_string = location(to) + default_mode_string = "[{mode: WALK}, {mode: TRANSIT}]" + + do_build_params(opts, %{ + "fromPlace" => from_string, + "toPlace" => to_string, + "transportModes" => default_mode_string, + "walkReluctance" => 15, + "locale" => "\"en\"" + }) + end + + defp location(%NamedPosition{stop_id: stop_id} = np) when not is_nil(stop_id) do + "\"#{np.name}::mbta-ma-us:#{stop_id}\"" + end + + defp location(%NamedPosition{} = np) do + "\"#{np.name}::#{np.latitude},#{np.longitude}\"" + end + + defp do_build_params([], acc) do + {:ok, acc} + end + + defp do_build_params([{:wheelchair_accessible?, bool} | rest], acc) when is_boolean(bool) do + acc = + if bool do + put_in(acc["wheelchair"], "true") + else + acc + end + + do_build_params(rest, acc) + end + + defp do_build_params([{:depart_at, %DateTime{} = datetime} | rest], acc) do + acc = do_date_time("false", datetime, acc) + do_build_params(rest, acc) + end + + defp do_build_params([{:arrive_by, %DateTime{} = datetime} | rest], acc) do + acc = do_date_time("true", datetime, acc) + do_build_params(rest, acc) + end + + defp do_build_params([{:mode, []} | rest], acc) do + do_build_params(rest, acc) + end + + defp do_build_params([{:mode, [_ | _] = modes} | rest], acc) do + all_modes = Enum.map(modes, fn m -> "{mode: #{m}}" end) + joined_modes = "[#{Enum.join(all_modes, ", ")}, {mode: WALK}]" + do_build_params(rest, Map.put(acc, "transportModes", joined_modes)) + end + + defp do_build_params([{:optimize_for, :less_walking} | rest], acc) do + do_build_params(rest, Map.put(acc, "walkReluctance", 27)) + end + + defp do_build_params([{:optimize_for, :fewest_transfers} | rest], acc) do + do_build_params(rest, Map.put(acc, "transferPenalty", 100)) + end + + # param is used for testing, ignore + defp do_build_params([{:root_url, _} | rest], acc) do + do_build_params(rest, acc) + end + + defp do_build_params([option | _], _) do + {:error, {:bad_param, option}} + end + + defp do_date_time(arriveBy, %DateTime{} = datetime, acc) do + local = + Timex.to_datetime(datetime, Application.fetch_env!(:mobile_app_backend, :timezone)) + + date = Timex.format!(local, "\"{ISOdate}\"") + time = Timex.format!(local, "\"{h12}:{0m}{am}\"") + + Map.merge(acc, %{ + "date" => date, + "time" => time, + "arriveBy" => arriveBy + }) + end +end diff --git a/lib/open_trip_planner_client/parser.ex b/lib/open_trip_planner_client/parser.ex new file mode 100644 index 00000000..53747379 --- /dev/null +++ b/lib/open_trip_planner_client/parser.ex @@ -0,0 +1,181 @@ +defmodule OpenTripPlannerClient.Parser do + @moduledoc false + require Logger + + alias OpenTripPlannerClient.{ + Itinerary, + Leg, + NamedPosition, + PersonalDetail, + PersonalDetail.Step, + TransitDetail + } + + @transit_modes ~w(SUBWAY TRAM BUS RAIL FERRY)s + + @spec parse_ql(map(), boolean()) :: + {:ok, [Itinerary.t()]} | {:error, OpenTripPlannerClient.error()} + def parse_ql(%{"data" => %{"plan" => nil}, "errors" => [head | _]}, _accessible?) do + _ = + Logger.warning(fn -> + "#{__MODULE__} trip_plan=error message=#{inspect(head["message"])}" + end) + + {:error, :unknown} + end + + def parse_ql(%{"data" => data}, accessible?) do + parse_map(data, accessible?) + end + + @spec parse_map(map(), boolean()) :: + {:ok, [Itinerary.t()]} | {:error, OpenTripPlannerClient.error()} + defp parse_map(%{"plan" => %{"routingErrors" => [head | _]}}, accessible?) do + _ = + Logger.warning(fn -> + "#{__MODULE__} trip_plan=error message=#{inspect(head["code"])}" + end) + + {:error, error_message_atom(head["code"], accessible?: accessible?)} + end + + defp parse_map(json, _) do + _ = + Logger.info(fn -> + "#{__MODULE__} trip_plan=success count=#{Enum.count(json["plan"]["itineraries"])}" + end) + + {:ok, Enum.map(json["plan"]["itineraries"], &parse_itinerary(&1, json["requestParameters"]))} + end + + @spec error_message_atom(String.t(), Keyword.t()) :: OpenTripPlannerClient.error() + defp error_message_atom("OUTSIDE_BOUNDS", _opts), do: :outside_bounds + defp error_message_atom("REQUEST_TIMEOUT", _opts), do: :timeout + defp error_message_atom("NO_TRANSIT_TIMES", _opts), do: :no_transit_times + defp error_message_atom("TOO_CLOSE", _opts), do: :too_close + defp error_message_atom("PATH_NOT_FOUND", accessible?: true), do: :location_not_accessible + defp error_message_atom("PATH_NOT_FOUND", accessible?: false), do: :path_not_found + defp error_message_atom("LOCATION_NOT_ACCESSIBLE", _opts), do: :location_not_accessible + defp error_message_atom("NO_STOPS_IN_RANGE", _opts), do: :location_not_accessible + defp error_message_atom(_, _opts), do: :unknown + + defp parse_itinerary(json, request_params) do + %Itinerary{ + start: parse_time(json["startTime"]), + stop: parse_time(json["endTime"]), + legs: Enum.map(json["legs"], &parse_leg/1), + accessible?: request_params["wheelchair"] == "true" + } + end + + defp parse_time(ms_after_epoch) do + {:ok, ms_after_epoch_dt} = + ms_after_epoch + |> Integer.floor_div(1000) + |> FastLocalDatetime.unix_to_datetime( + Application.fetch_env!(:mobile_app_backend, :timezone) + ) + + ms_after_epoch_dt + end + + defp parse_leg(json) do + %Leg{ + start: parse_time(json["startTime"]), + stop: parse_time(json["endTime"]), + mode: parse_mode(json), + from: parse_named_position(json["from"], "stop"), + to: parse_named_position(json["to"], "stop"), + polyline: json["legGeometry"]["points"], + name: json["route"], + long_name: json["routeLongName"], + type: json["agencyId"], + url: json["agencyUrl"], + description: json["mode"] + } + end + + defp parse_named_position(json, "stop") do + stop = json["stop"] + + %NamedPosition{ + name: json["name"], + stop_id: if(stop, do: id_after_colon(stop["gtfsId"])), + longitude: json["lon"], + latitude: json["lat"] + } + end + + defp parse_named_position(json, id_field) do + %NamedPosition{ + name: json["name"], + stop_id: if(id_str = json[id_field], do: id_after_colon(id_str)), + longitude: json["lon"], + latitude: json["lat"] + } + end + + defp parse_mode(%{"mode" => "WALK"} = json) do + %PersonalDetail{ + distance: json["distance"], + steps: Enum.map(json["steps"], &parse_step/1) + } + end + + defp parse_mode(%{"mode" => mode} = json) when mode in @transit_modes do + %TransitDetail{ + route_id: id_after_colon(json["route"]["gtfsId"]), + trip_id: id_after_colon(json["trip"]["gtfsId"]), + intermediate_stop_ids: Enum.map(json["intermediateStops"], &id_after_colon(&1["gtfsId"])) + } + end + + defp parse_step(json) do + %Step{ + distance: json["distance"], + relative_direction: parse_relative_direction(json["relativeDirection"]), + absolute_direction: parse_absolute_direction(json["absoluteDirection"]), + street_name: json["streetName"] + } + end + + # http://dev.opentripplanner.org/apidoc/1.0.0/json_RelativeDirection.html + for dir <- ~w( + depart + hard_left + left + slightly_left + continue + slightly_right + right + hard_right + circle_clockwise + circle_counterclockwise + elevator + uturn_left + uturn_right + enter_station + exit_station + follow_signs)a do + defp parse_relative_direction(unquote(String.upcase(Atom.to_string(dir)))), do: unquote(dir) + end + + # http://dev.opentripplanner.org/apidoc/1.0.0/json_AbsoluteDirection.html + for dir <- ~w(north northeast east southeast south southwest west northwest)a do + defp parse_absolute_direction(unquote(String.upcase(Atom.to_string(dir)))), do: unquote(dir) + end + + defp parse_absolute_direction(nil), do: nil + + defp id_after_colon(feed_colon_id) do + [feed, id] = String.split(feed_colon_id, ":", parts: 2) + + # feed id is either mbta-ma-us (MBTA) or 22722274 (Massport), if it's neither, assume it's MBTA + case feed do + "mbta-ma-us" -> id + "22722274" -> "Massport-" <> id + "2272_2274" -> "Massport-" <> id + _ -> id + end + end +end diff --git a/lib/open_trip_planner_client/personal_detail.ex b/lib/open_trip_planner_client/personal_detail.ex new file mode 100644 index 00000000..bdeaa750 --- /dev/null +++ b/lib/open_trip_planner_client/personal_detail.ex @@ -0,0 +1,55 @@ +defmodule OpenTripPlannerClient.PersonalDetail do + @moduledoc """ + Additional information for legs which are taken on personal transportation + """ + defstruct distance: 0.0, + steps: [] + + @type t :: %__MODULE__{ + distance: float, + steps: [__MODULE__.Step.t()] + } +end + +defmodule OpenTripPlannerClient.PersonalDetail.Step do + @moduledoc """ + A turn-by-turn direction + """ + defstruct distance: 0.0, + relative_direction: :depart, + absolute_direction: :north, + street_name: "" + + @type t :: %__MODULE__{ + distance: float, + relative_direction: relative_direction, + absolute_direction: absolute_direction | nil + } + @type relative_direction :: + :depart + | :slightly_left + | :left + | :hard_left + | :slightly_right + | :right + | :hard_right + | :continue + | :circle_clockwise + | :circle_counterclockwise + | :elevator + | :uturn_left + | :uturn_right + | :enter_station + | :exit_station + | :follow_signs + + @type absolute_direction :: + :north + | :northeast + | :east + | :southeast + | :south + | :southwest + | :west + | :northwest +end diff --git a/lib/open_trip_planner_client/transit_detail.ex b/lib/open_trip_planner_client/transit_detail.ex new file mode 100644 index 00000000..6717fa9d --- /dev/null +++ b/lib/open_trip_planner_client/transit_detail.ex @@ -0,0 +1,13 @@ +defmodule OpenTripPlannerClient.TransitDetail do + @moduledoc """ + Additional information for legs taken on public transportation + """ + + defstruct route_id: "", trip_id: "", intermediate_stop_ids: [] + + @type t :: %__MODULE__{ + route_id: String.t(), + trip_id: String.t(), + intermediate_stop_ids: [String.t()] + } +end diff --git a/mix.exs b/mix.exs index f26926ad..10732934 100644 --- a/mix.exs +++ b/mix.exs @@ -63,10 +63,12 @@ defmodule MobileAppBackend.MixProject do {:credo, "~> 1.7", only: [:dev, :test]}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:bypass, "~> 2.1", only: :test}, - {:req, "~> 0.4.8"}, + {:req, "~> 0.3"}, {:sentry, "~> 10.0"}, {:timex, "~> 3.7"}, - {:lcov_ex, "~> 0.3", only: [:test], runtime: false} + {:lcov_ex, "~> 0.3", only: [:test], runtime: false}, + {:absinthe_client, "~> 0.1.0"}, + {:fast_local_datetime, "~> 1.0"} ] end diff --git a/mix.lock b/mix.lock index 92f77527..f4df6281 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "absinthe_client": {:hex, :absinthe_client, "0.1.0", "a3bafc1dff141073a2a7fd926942fb10afb4d45295f0b6df46f6f1955ececaac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:req, "~> 0.3.0", [hex: :req, repo: "hexpm", optional: false]}, {:slipstream, "~> 1.0", [hex: :slipstream, repo: "hexpm", optional: false]}], "hexpm", "a7ec3e13da9b463cb024dba4733c2fa31a0690a3bfa897b9df6bdd544a4d6f91"}, "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, @@ -15,6 +16,7 @@ "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, + "fast_local_datetime": {:hex, :fast_local_datetime, "1.0.1", "1a7babf4a7bbcadd665c635e4b585ecfb24f07349ea5391e45458f239122c4bc", [:mix], [{:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "b3ea84154c5937e3894247edb60f57635f77e5f2ec2ba4742b7522081d7acf6d"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "finch": {:hex, :finch, "0.17.0", "17d06e1d44d891d20dbd437335eebe844e2426a0cd7e3a3e220b461127c73f70", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8d014a661bb6a437263d4b5abf0bcbd3cf0deb26b1e8596f2a271d22e48934c7"}, "floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"}, @@ -29,6 +31,7 @@ "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, + "mint_web_socket": {:hex, :mint_web_socket, "1.0.3", "aab42fff792a74649916236d0b01f560a0b3f03ca5dea693c230d1c44736b50e", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "ca3810ca44cc8532e3dce499cc17f958596695d226bb578b2fbb88c09b5954b0"}, "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, @@ -43,8 +46,9 @@ "plug_cowboy": {:hex, :plug_cowboy, "2.6.2", "753611b23b29231fb916b0cdd96028084b12aff57bfd7b71781bd04b1dbeb5c9", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "951ed2433df22f4c97b85fdb145d4cee561f36b74854d64c06d896d7cd2921a7"}, "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "req": {:hex, :req, "0.4.8", "2b754a3925ddbf4ad78c56f30208ced6aefe111a7ea07fb56c23dccc13eb87ae", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7146e51d52593bb7f20d00b5308a5d7d17d663d6e85cd071452b613a8277100c"}, + "req": {:hex, :req, "0.3.12", "f84c2f9e7cc71c81d7cbeacf7c61e763e53ab5f3065703792a4ab264b4f22672", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c91103d4d1c8edeba90c84e0ba223a59865b673eaab217bfd17da3aa54ab136c"}, "sentry": {:hex, :sentry, "10.1.0", "5d73c23deb5d95f3027fbb09801bd8e787065be61f0065418aed3961becbbe9f", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "f4319e7491133046912b4cf7cbe6f5226b309275d1a6d05386cce2ac7f97b2d2"}, + "slipstream": {:hex, :slipstream, "1.1.0", "e3581e9bc73036e4283b33447475499d18c813c7662aa6b86e131633a7e912f3", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mint_web_socket, "~> 0.2 or ~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.1 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "66eb1ac7c43573511b5bad90c24c128bb4e69f588bff65d0c409adf4c7eb02e6"}, "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, diff --git a/test/fixture/README.md b/test/fixture/README.md new file mode 100644 index 00000000..8c6c505c --- /dev/null +++ b/test/fixture/README.md @@ -0,0 +1,5 @@ +# Fixtures + +## north_station_to_park_plaza.json + +URL: http://mbta-otp-dev.us-east-1.elasticbeanstalk.com/otp/routers/default/plan?fromPlace=stop%20North%20Station%20::42.365551%2C-71.061251&toPlace=42.348777%2C-71.066481&time=1%3A46pm&date=05-19-2017&mode=TRANSIT%2CWALK&maxWalkDistance=804.672&arriveBy=false&wheelchair=true&showIntermediateStops=true&format=json&locale=en diff --git a/test/fixture/north_station_to_park_plaza.json b/test/fixture/north_station_to_park_plaza.json new file mode 100644 index 00000000..8516034d --- /dev/null +++ b/test/fixture/north_station_to_park_plaza.json @@ -0,0 +1,651 @@ +{ + "requestParameters": { + "date": "05-19-2017", + "mode": "TRANSIT,WALK", + "arriveBy": "false", + "wheelchair": "true", + "showIntermediateStops": "true", + "fromPlace": "stop North Station ::42.365551,-71.061251", + "format": "json", + "toPlace": "42.348777,-71.066481", + "time": "1:46pm", + "maxWalkDistance": "804.672", + "locale": "en" + }, + "plan": { + "date": 1495215960000, + "from": { + "name": "stop North Station ", + "lon": -71.061251, + "lat": 42.365551, + "orig": "stop North Station ", + "vertexType": "NORMAL" + }, + "to": { + "name": "Destination", + "lon": -71.066481, + "lat": 42.348777, + "orig": "", + "vertexType": "NORMAL" + }, + "itineraries": [{ + "duration": 740, + "startTime": 1495216259000, + "endTime": 1495216999000, + "walkTime": 258, + "transitTime": 480, + "waitingTime": 2, + "walkDistance": 329.35159398374935, + "walkLimitExceeded": false, + "elevationLost": 0.0, + "elevationGained": 0.0, + "transfers": 0, + "legs": [{ + "startTime": 1495216260000, + "endTime": 1495216740000, + "departureDelay": 0, + "arrivalDelay": 0, + "realTime": false, + "distance": 1995.478514770973, + "pathway": false, + "mode": "SUBWAY", + "route": { + "gtfsId": "1:Orange", + "longName": "Orange Line" + }, + "trip": { + "gtfsId": "1:33932853" + }, + "agencyName": "MBTA", + "agencyUrl": "http://www.mbta.com", + "agencyTimeZoneOffset": -14400000, + "routeColor": "E87200", + "routeType": 1, + "routeId": "1:Orange", + "routeTextColor": "FFFFFF", + "interlineWithPreviousLeg": false, + "tripBlockId": "O903_-12", + "headsign": "Forest Hills", + "agencyId": "1", + "serviceDate": "20170519", + "from": { + "name": "North Station - Orange Line Inbound", + "stopId": "1:70026", + "stopCode": "70026", + "lon": -71.06129, + "lat": 42.365577, + "departure": 1495216260000, + "orig": "stop North Station ", + "stopIndex": 6, + "stopSequence": 60, + "vertexType": "TRANSIT" + }, + "to": { + "name": "Tufts Medical Center - Outbound", + "stop": { + "gtfsId": "1:70016" + }, + "lon": -71.063917, + "lat": 42.349662, + "arrival": 1495216740000, + "departure": 1495216741000, + "stopIndex": 11, + "stopSequence": 110, + "vertexType": "TRANSIT" + }, + "legGeometry": { + "points": "_rqaGz_vpLPOROtHkG`CuCh@g@b@W??FCZQr@GlMg@zG@??R?vC?P@TN`ElDvAvBdBrBrA~A??@@t@r@hAtAjDdDb@d@t@r@TJj@Lj@@l@Bz@H@?P@fD`@|B^XJRLx@`AzBvB^F", + "length": 43 + }, + "routeLongName": "Orange Line", + "rentedBike": false, + "transitLeg": true, + "duration": 480.0, + "intermediateStops": [{ + "name": "Haymarket - Orange Line Inbound", + "gtfsId": "1:70024", + "stopCode": "70024", + "lon": -71.05829, + "lat": 42.363021, + "arrival": 1495216320000, + "departure": 1495216320000, + "stopIndex": 7, + "stopSequence": 70, + "vertexType": "TRANSIT" + }, { + "name": "State Street - to Forest Hills", + "gtfsId": "1:70022", + "stopCode": "70022", + "lon": -71.057598, + "lat": 42.358978, + "arrival": 1495216440000, + "departure": 1495216440000, + "stopIndex": 8, + "stopSequence": 80, + "vertexType": "TRANSIT" + }, { + "name": "Downtown Crossing - to Forest Hills", + "gtfsId": "1:70020", + "stopCode": "70020", + "lon": -71.060225, + "lat": 42.355518000000004, + "arrival": 1495216560000, + "departure": 1495216560000, + "stopIndex": 9, + "stopSequence": 90, + "vertexType": "TRANSIT" + }, { + "name": "Chinatown - Outbound", + "gtfsId": "1:70018", + "stopCode": "70018", + "lon": -71.062752, + "lat": 42.352547, + "arrival": 1495216680000, + "departure": 1495216680000, + "stopIndex": 10, + "stopSequence": 100, + "vertexType": "TRANSIT" + }], + "steps": [] + }, { + "startTime": 1495216741000, + "endTime": 1495216999000, + "departureDelay": 0, + "arrivalDelay": 0, + "realTime": false, + "distance": 329.314, + "pathway": false, + "mode": "WALK", + "route": { + "gtfsId": "1:Orange", + "longName": "Orange Line" + }, + "trip": { + "gtfsId": "1:33932852" + }, + "agencyTimeZoneOffset": -14400000, + "interlineWithPreviousLeg": false, + "from": { + "name": "Tufts Medical Center - Outbound", + "stop": { + "gtfsId": "1:70016" + }, + "lon": -71.063917, + "lat": 42.349662, + "arrival": 1495216740000, + "departure": 1495216741000, + "stopIndex": 11, + "stopSequence": 110, + "vertexType": "TRANSIT" + }, + "to": { + "name": "Destination", + "lon": -71.066481, + "lat": 42.348777, + "arrival": 1495216999000, + "orig": "", + "vertexType": "NORMAL" + }, + "legGeometry": { + "points": "clnaGvsvpL@?ZNF@\\Jr@NdB\\PDARa@vF[zD", + "length": 11 + }, + "rentedBike": false, + "transitLeg": false, + "duration": 258.0, + "intermediateStops": [], + "steps": [{ + "distance": 138.02, + "relativeDirection": "DEPART", + "streetName": "Washington Street", + "absoluteDirection": "SOUTH", + "stayOn": false, + "area": false, + "bogusName": false, + "lon": -71.06379006319844, + "lat": 42.34962271200275, + "elevation": [] + }, { + "distance": 111.909, + "relativeDirection": "RIGHT", + "streetName": "Oak Street West", + "absoluteDirection": "WEST", + "stayOn": false, + "area": false, + "bogusName": false, + "lon": -71.064203, + "lat": 42.348421800000004, + "elevation": [] + }, { + "distance": 79.385, + "relativeDirection": "CONTINUE", + "streetName": "Tremont Street", + "absoluteDirection": "WEST", + "stayOn": false, + "area": false, + "bogusName": false, + "lon": -71.06554100000001, + "lat": 42.348609, + "elevation": [] + }] + }], + "tooSloped": false + }, { + "duration": 800, + "startTime": 1495216739000, + "endTime": 1495217539000, + "walkTime": 258, + "transitTime": 540, + "waitingTime": 2, + "walkDistance": 329.35159398374935, + "walkLimitExceeded": false, + "elevationLost": 0.0, + "elevationGained": 0.0, + "transfers": 0, + "legs": [{ + "startTime": 1495216740000, + "endTime": 1495217280000, + "departureDelay": 0, + "arrivalDelay": 0, + "realTime": false, + "distance": 1995.478514770973, + "pathway": false, + "mode": "SUBWAY", + "route": { + "gtfsId": "1:Orange", + "longName": "Orange Line" + }, + "trip": { + "gtfsId": "1:33932854" + }, + "agencyName": "MBTA", + "agencyUrl": "http://www.mbta.com", + "agencyTimeZoneOffset": -14400000, + "routeColor": "E87200", + "routeType": 1, + "routeId": "1:Orange", + "routeTextColor": "FFFFFF", + "interlineWithPreviousLeg": false, + "tripBlockId": "O903_-13", + "headsign": "Forest Hills", + "agencyId": "1", + "tripId": "1:33932854", + "serviceDate": "20170519", + "from": { + "name": "North Station - Orange Line Inbound", + "stopId": "1:70026", + "stopCode": "70026", + "lon": -71.06129, + "lat": 42.365577, + "departure": 1495216740000, + "orig": "stop North Station ", + "stopIndex": 6, + "stopSequence": 60, + "vertexType": "TRANSIT" + }, + "to": { + "name": "Tufts Medical Center - Outbound", + "stop": { + "gtfsId": "1:70016" + }, + "lon": -71.063917, + "lat": 42.349662, + "arrival": 1495217280000, + "departure": 1495217281000, + "stopIndex": 11, + "stopSequence": 110, + "vertexType": "TRANSIT" + }, + "legGeometry": { + "points": "_rqaGz_vpLPOROtHkG`CuCh@g@b@W??FCZQr@GlMg@zG@??R?vC?P@TN`ElDvAvBdBrBrA~A??@@t@r@hAtAjDdDb@d@t@r@TJj@Lj@@l@Bz@H@?P@fD`@|B^XJRLx@`AzBvB^F", + "length": 43 + }, + "routeLongName": "Orange Line", + "rentedBike": false, + "transitLeg": true, + "duration": 540.0, + "intermediateStops": [{ + "name": "Haymarket - Orange Line Inbound", + "gtfsId": "1:70024", + "stopCode": "70024", + "lon": -71.05829, + "lat": 42.363021, + "arrival": 1495216860000, + "departure": 1495216860000, + "stopIndex": 7, + "stopSequence": 70, + "vertexType": "TRANSIT" + }, { + "name": "State Street - to Forest Hills", + "gtfsId": "1:70022", + "stopCode": "70022", + "lon": -71.057598, + "lat": 42.358978, + "arrival": 1495216980000, + "departure": 1495216980000, + "stopIndex": 8, + "stopSequence": 80, + "vertexType": "TRANSIT" + }, { + "name": "Downtown Crossing - to Forest Hills", + "gtfsId": "1:70020", + "stopCode": "70020", + "lon": -71.060225, + "lat": 42.355518000000004, + "arrival": 1495217100000, + "departure": 1495217100000, + "stopIndex": 9, + "stopSequence": 90, + "vertexType": "TRANSIT" + }, { + "name": "Chinatown - Outbound", + "gtfsId": "1:70018", + "stopCode": "70018", + "lon": -71.062752, + "lat": 42.352547, + "arrival": 1495217220000, + "departure": 1495217220000, + "stopIndex": 10, + "stopSequence": 100, + "vertexType": "TRANSIT" + }], + "steps": [] + }, { + "startTime": 1495217281000, + "endTime": 1495217539000, + "departureDelay": 0, + "arrivalDelay": 0, + "realTime": false, + "distance": 329.314, + "pathway": false, + "mode": "WALK", + "route": { + "gtfsId": "1:Orange", + "longName": "Orange Line" + }, + "trip": { + "gtfsId": "1:33932854" + }, + "agencyTimeZoneOffset": -14400000, + "interlineWithPreviousLeg": false, + "from": { + "name": "Tufts Medical Center - Outbound", + "stop": { + "gtfsId": "1:70016" + }, + "lon": -71.063917, + "lat": 42.349662, + "arrival": 1495217280000, + "departure": 1495217281000, + "stopIndex": 11, + "stopSequence": 110, + "vertexType": "TRANSIT" + }, + "to": { + "name": "Destination", + "lon": -71.066481, + "lat": 42.348777, + "arrival": 1495217539000, + "orig": "", + "vertexType": "NORMAL" + }, + "legGeometry": { + "points": "clnaGvsvpL@?ZNF@\\Jr@NdB\\PDARa@vF[zD", + "length": 11 + }, + "rentedBike": false, + "transitLeg": false, + "duration": 258.0, + "intermediateStops": [], + "steps": [{ + "distance": 138.02, + "relativeDirection": "DEPART", + "streetName": "Washington Street", + "absoluteDirection": "SOUTH", + "stayOn": false, + "area": false, + "bogusName": false, + "lon": -71.06379006319844, + "lat": 42.34962271200275, + "elevation": [] + }, { + "distance": 111.909, + "relativeDirection": "RIGHT", + "streetName": "Oak Street West", + "absoluteDirection": "WEST", + "stayOn": false, + "area": false, + "bogusName": false, + "lon": -71.064203, + "lat": 42.348421800000004, + "elevation": [] + }, { + "distance": 79.385, + "relativeDirection": "CONTINUE", + "streetName": "Tremont Street", + "absoluteDirection": "WEST", + "stayOn": false, + "area": false, + "bogusName": false, + "lon": -71.06554100000001, + "lat": 42.348609, + "elevation": [] + }] + }], + "tooSloped": false + }, { + "duration": 800, + "startTime": 1495217219000, + "endTime": 1495218019000, + "walkTime": 258, + "transitTime": 540, + "waitingTime": 2, + "walkDistance": 329.35159398374935, + "walkLimitExceeded": false, + "elevationLost": 0.0, + "elevationGained": 0.0, + "transfers": 0, + "legs": [{ + "startTime": 1495217220000, + "endTime": 1495217760000, + "departureDelay": 0, + "arrivalDelay": 0, + "realTime": false, + "distance": 1995.478514770973, + "pathway": false, + "mode": "SUBWAY", + "route": { + "gtfsId": "1:Orange", + "longName": "Orange Line" + }, + "trip": { + "gtfsId": "1:33932855" + }, + "agencyName": "MBTA", + "agencyUrl": "http://www.mbta.com", + "agencyTimeZoneOffset": -14400000, + "routeColor": "E87200", + "routeType": 1, + "routeTextColor": "FFFFFF", + "interlineWithPreviousLeg": false, + "tripBlockId": "O903_-15", + "headsign": "Forest Hills", + "agencyId": "1", + "serviceDate": "20170519", + "from": { + "name": "North Station - Orange Line Inbound", + "stopId": "1:70026", + "stopCode": "70026", + "lon": -71.06129, + "lat": 42.365577, + "departure": 1495217220000, + "orig": "stop North Station ", + "stopIndex": 6, + "stopSequence": 60, + "vertexType": "TRANSIT" + }, + "to": { + "name": "Tufts Medical Center - Outbound", + "stop": { + "gtfsId": "1:70016" + }, + "lon": -71.063917, + "lat": 42.349662, + "arrival": 1495217760000, + "departure": 1495217761000, + "stopIndex": 11, + "stopSequence": 110, + "vertexType": "TRANSIT" + }, + "legGeometry": { + "points": "_rqaGz_vpLPOROtHkG`CuCh@g@b@W??FCZQr@GlMg@zG@??R?vC?P@TN`ElDvAvBdBrBrA~A??@@t@r@hAtAjDdDb@d@t@r@TJj@Lj@@l@Bz@H@?P@fD`@|B^XJRLx@`AzBvB^F", + "length": 43 + }, + "routeLongName": "Orange Line", + "rentedBike": false, + "transitLeg": true, + "duration": 540.0, + "intermediateStops": [{ + "name": "Haymarket - Orange Line Inbound", + "gtfsId": "1:70024", + "stopCode": "70024", + "lon": -71.05829, + "lat": 42.363021, + "arrival": 1495217340000, + "departure": 1495217340000, + "stopIndex": 7, + "stopSequence": 70, + "vertexType": "TRANSIT" + }, { + "name": "State Street - to Forest Hills", + "gtfsId": "1:70022", + "stopCode": "70022", + "lon": -71.057598, + "lat": 42.358978, + "arrival": 1495217460000, + "departure": 1495217460000, + "stopIndex": 8, + "stopSequence": 80, + "vertexType": "TRANSIT" + }, { + "name": "Downtown Crossing - to Forest Hills", + "gtfsId": "1:70020", + "stopCode": "70020", + "lon": -71.060225, + "lat": 42.355518000000004, + "arrival": 1495217580000, + "departure": 1495217580000, + "stopIndex": 9, + "stopSequence": 90, + "vertexType": "TRANSIT" + }, { + "name": "Chinatown - Outbound", + "gtfsId": "1:70018", + "stopCode": "70018", + "lon": -71.062752, + "lat": 42.352547, + "arrival": 1495217700000, + "departure": 1495217700000, + "stopIndex": 10, + "stopSequence": 100, + "vertexType": "TRANSIT" + }], + "steps": [] + }, { + "startTime": 1495217761000, + "endTime": 1495218019000, + "departureDelay": 0, + "arrivalDelay": 0, + "realTime": false, + "distance": 329.314, + "pathway": false, + "mode": "WALK", + "route": { + "gtfsId": "1:Orange", + "longName": "Orange Line" + }, + "trip": { + "gtfsId": "1:33932856" + }, + "agencyTimeZoneOffset": -14400000, + "interlineWithPreviousLeg": false, + "from": { + "name": "Tufts Medical Center - Outbound", + "stop": { + "gtfsId": "1:70016" + }, + "lon": -71.063917, + "lat": 42.349662, + "arrival": 1495217760000, + "departure": 1495217761000, + "stopIndex": 11, + "stopSequence": 110, + "vertexType": "TRANSIT" + }, + "to": { + "name": "Destination", + "lon": -71.066481, + "lat": 42.348777, + "arrival": 1495218019000, + "orig": "", + "vertexType": "NORMAL" + }, + "legGeometry": { + "points": "clnaGvsvpL@?ZNF@\\Jr@NdB\\PDARa@vF[zD", + "length": 11 + }, + "rentedBike": false, + "transitLeg": false, + "duration": 258.0, + "intermediateStops": [], + "steps": [{ + "distance": 138.02, + "relativeDirection": "DEPART", + "streetName": "Washington Street", + "absoluteDirection": "SOUTH", + "stayOn": false, + "area": false, + "bogusName": false, + "lon": -71.06379006319844, + "lat": 42.34962271200275, + "elevation": [] + }, { + "distance": 111.909, + "relativeDirection": "RIGHT", + "streetName": "Oak Street West", + "absoluteDirection": "WEST", + "stayOn": false, + "area": false, + "bogusName": false, + "lon": -71.064203, + "lat": 42.348421800000004, + "elevation": [] + }, { + "distance": 79.385, + "relativeDirection": "CONTINUE", + "streetName": "Tremont Street", + "absoluteDirection": "WEST", + "stayOn": false, + "area": false, + "bogusName": false, + "lon": -71.06554100000001, + "lat": 42.348609, + "elevation": [] + }] + }], + "tooSloped": false + }] + }, + "debugOutput": { + "precalculationTime": 28, + "pathCalculationTime": 16, + "pathTimes": [5, 6, 4], + "renderingTime": 0, + "totalTime": 44, + "timedOut": false + }, + "elevationMetadata": { + "ellipsoidToGeoidDifference": -29.913641103082842, + "geoidElevation": false + } +} diff --git a/test/open_trip_planner_client/http_test.exs b/test/open_trip_planner_client/http_test.exs new file mode 100644 index 00000000..e817dc1f --- /dev/null +++ b/test/open_trip_planner_client/http_test.exs @@ -0,0 +1,119 @@ +defmodule OpenTripPlannerClient.HttpTest do + @moduledoc """ + Tests for OpenTripPlanner that require overriding the OTP host or making + external requests. + + We pull these into a separate module so that the main body of tests can + remain async: true. + + """ + use ExUnit.Case + import OpenTripPlannerClient + alias OpenTripPlannerClient.{ItineraryTag, NamedPosition} + import Plug.Conn, only: [send_resp: 3] + + setup context do + if context[:external] do + :ok + else + bypass = Bypass.open() + host = "http://localhost:#{bypass.port}" + old_otp_url = Application.get_env(:mobile_app_backend, :otp_url) + old_level = Logger.level() + + on_exit(fn -> + Application.put_env(:mobile_app_backend, :otp_url, old_otp_url) + Logger.configure(level: old_level) + end) + + Application.put_env(:mobile_app_backend, :otp_url, host) + Logger.configure(level: :info) + + {:ok, %{bypass: bypass}} + end + end + + describe "plan/3 with fixture data" do + @fixture File.read!("test/fixture/north_station_to_park_plaza.json") + + test "can apply tags", %{bypass: bypass} do + Bypass.expect_once(bypass, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(:ok, ~s({"data":#{@fixture}})) + end) + + {:ok, itineraries} = + plan( + %NamedPosition{ + name: "North Station", + stop_id: "place-north", + latitude: 42.365551, + longitude: -71.061251 + }, + %NamedPosition{latitude: 42.348777, longitude: -71.066481}, + tags: [ + ItineraryTag.EarliestArrival, + ItineraryTag.LeastWalking, + ItineraryTag.ShortestTrip + ] + ) + + tags = itineraries |> Enum.map(&Enum.sort(&1.tags)) + + assert tags == [ + [:earliest_arrival, :least_walking, :shortest_trip], + [:least_walking], + [:least_walking] + ] + end + end + + describe "plan/3 with real OTP" do + @describetag :external + + test "can make a basic plan with OTP" do + north_station = %NamedPosition{ + name: "North Station", + stop_id: "place-north", + latitude: 42.365551, + longitude: -71.061251 + } + + boylston = %NamedPosition{latitude: 42.348777, longitude: -71.066481} + + assert {:ok, itineraries} = + plan(north_station, boylston, depart_at: DateTime.utc_now()) + + refute itineraries == [] + end + end + + describe "error handling/logging" do + @tag :capture_log + test "HTTP errors are converted to error tuples", %{bypass: bypass} do + Bypass.expect(bypass, fn conn -> + send_resp(conn, 500, "{}") + end) + + assert {:error, _} = + plan( + %NamedPosition{latitude: 1, longitude: 1}, + %NamedPosition{latitude: 2, longitude: 2}, + depart_at: DateTime.utc_now() + ) + end + + @tag :capture_log + test "connection errors are converted to error tuples", %{bypass: bypass} do + Bypass.down(bypass) + + assert {:error, _} = + plan( + %NamedPosition{latitude: 1, longitude: 1}, + %NamedPosition{latitude: 2, longitude: 2}, + depart_at: DateTime.utc_now() + ) + end + end +end diff --git a/test/open_trip_planner_client/itinerary_tag/earliest_arrival_test.exs b/test/open_trip_planner_client/itinerary_tag/earliest_arrival_test.exs new file mode 100644 index 00000000..d6a78137 --- /dev/null +++ b/test/open_trip_planner_client/itinerary_tag/earliest_arrival_test.exs @@ -0,0 +1,18 @@ +defmodule OpenTripPlannerClient.ItineraryTag.EarliestArrivalTest do + use ExUnit.Case, async: true + alias OpenTripPlannerClient.{Itinerary, ItineraryTag} + + test "works" do + itineraries = [ + %Itinerary{start: ~U[2024-01-03 04:00:00Z], stop: ~U[2024-01-03 05:15:00Z]}, + %Itinerary{start: ~U[2024-01-03 04:15:00Z], stop: ~U[2024-01-03 05:30:00Z]}, + %Itinerary{start: ~U[2024-01-03 04:22:00Z], stop: ~U[2024-01-03 05:15:00Z]} + ] + + tags = + ItineraryTag.apply_tag(ItineraryTag.EarliestArrival, itineraries) + |> Enum.map(&(&1.tags |> Enum.sort())) + + assert tags == [[:earliest_arrival], [], [:earliest_arrival]] + end +end diff --git a/test/open_trip_planner_client/itinerary_tag/least_walking_test.exs b/test/open_trip_planner_client/itinerary_tag/least_walking_test.exs new file mode 100644 index 00000000..25f728ef --- /dev/null +++ b/test/open_trip_planner_client/itinerary_tag/least_walking_test.exs @@ -0,0 +1,30 @@ +defmodule OpenTripPlannerClient.ItineraryTag.LeastWalkingTest do + use ExUnit.Case, async: true + alias OpenTripPlannerClient.{Itinerary, ItineraryTag, Leg, PersonalDetail, TransitDetail} + + test "works" do + itineraries = [ + %Itinerary{ + start: ~U[2024-01-03 04:00:00Z], + stop: ~U[2024-01-03 05:15:00Z], + legs: [%Leg{mode: %TransitDetail{}}] + }, + %Itinerary{ + start: ~U[2024-01-03 04:00:00Z], + stop: ~U[2024-01-03 05:15:00Z], + legs: [%Leg{mode: %PersonalDetail{distance: 10}}] + }, + %Itinerary{ + start: ~U[2024-01-03 04:00:00Z], + stop: ~U[2024-01-03 05:15:00Z], + legs: [%Leg{mode: %PersonalDetail{distance: 8}}, %Leg{mode: %PersonalDetail{distance: 8}}] + } + ] + + tags = + ItineraryTag.apply_tag(ItineraryTag.LeastWalking, itineraries) + |> Enum.map(&(&1.tags |> Enum.sort())) + + assert tags == [[:least_walking], [], []] + end +end diff --git a/test/open_trip_planner_client/itinerary_tag/shortest_trip_test.exs b/test/open_trip_planner_client/itinerary_tag/shortest_trip_test.exs new file mode 100644 index 00000000..c1a1d5a7 --- /dev/null +++ b/test/open_trip_planner_client/itinerary_tag/shortest_trip_test.exs @@ -0,0 +1,18 @@ +defmodule OpenTripPlannerClient.ItineraryTag.ShortestTripTest do + use ExUnit.Case, async: true + alias OpenTripPlannerClient.{Itinerary, ItineraryTag} + + test "works" do + itineraries = [ + %Itinerary{start: ~U[2024-01-03 04:00:00Z], stop: ~U[2024-01-03 05:15:00Z]}, + %Itinerary{start: ~U[2024-01-03 04:15:00Z], stop: ~U[2024-01-03 05:30:00Z]}, + %Itinerary{start: ~U[2024-01-03 04:22:00Z], stop: ~U[2024-01-03 05:15:00Z]} + ] + + tags = + ItineraryTag.apply_tag(ItineraryTag.ShortestTrip, itineraries) + |> Enum.map(&(&1.tags |> Enum.sort())) + + assert tags == [[], [], [:shortest_trip]] + end +end diff --git a/test/open_trip_planner_client/itinerary_tag_test.exs b/test/open_trip_planner_client/itinerary_tag_test.exs new file mode 100644 index 00000000..bc5e5f78 --- /dev/null +++ b/test/open_trip_planner_client/itinerary_tag_test.exs @@ -0,0 +1,22 @@ +defmodule OpenTripPlannerClient.ItineraryTagTest do + use ExUnit.Case, async: true + alias OpenTripPlannerClient.{Itinerary, ItineraryTag} + + defmodule BadTag do + @behaviour OpenTripPlannerClient.ItineraryTag + + def optimal, do: :max + def score(_), do: nil + def tag, do: :bad + end + + test "correctly ignores tags that are always nil" do + itineraries = [ + %Itinerary{start: ~U[2024-01-03 16:27:55Z], stop: ~U[2024-01-03 16:28:04Z]} + ] + + tags = ItineraryTag.apply_tag(BadTag, itineraries) |> Enum.map(&Enum.sort(&1.tags)) + + assert tags == [[]] + end +end diff --git a/test/open_trip_planner_client/itinerary_test.exs b/test/open_trip_planner_client/itinerary_test.exs new file mode 100644 index 00000000..15ca77e2 --- /dev/null +++ b/test/open_trip_planner_client/itinerary_test.exs @@ -0,0 +1,51 @@ +defmodule OpenTripPlannerClient.ItineraryTest do + use ExUnit.Case, async: true + alias OpenTripPlannerClient.{Itinerary, Leg, PersonalDetail, TransitDetail} + alias Test.Support.OpenTripPlannerClient, as: Support + + @from Support.random_stop() + @to Support.random_stop() + + describe "duration/1" do + test "calculates duration of itinerary" do + itinerary = %Itinerary{start: DateTime.from_unix!(10), stop: DateTime.from_unix!(13)} + + assert Itinerary.duration(itinerary) == 3 + end + end + + describe "walking_distance/1" do + test "calculates walking distance of itinerary" do + itinerary = %Itinerary{ + start: DateTime.from_unix!(10), + stop: DateTime.from_unix!(13), + legs: [ + %Leg{mode: %PersonalDetail{distance: 12.3}}, + %Leg{mode: %TransitDetail{}}, + %Leg{mode: %PersonalDetail{distance: 34.5}} + ] + } + + assert abs(Itinerary.walking_distance(itinerary) - 46.8) < 0.001 + end + end + + describe "same_itinerary?" do + test "Same itinerary is the same" do + itinerary = Support.itinerary(@from, @to) + assert Itinerary.same_itinerary?(itinerary, itinerary) + end + + test "itineraries with different start times are not the same" do + itinerary = Support.itinerary(@from, @to) + later_itinerary = %{itinerary | start: Timex.shift(itinerary.start, minutes: 40)} + refute Itinerary.same_itinerary?(itinerary, later_itinerary) + end + + test "Itineraries with different accessibility flags are the same" do + itinerary = Support.itinerary(@from, @to) + accessible_itinerary = %{itinerary | accessible?: true} + assert Itinerary.same_itinerary?(itinerary, accessible_itinerary) + end + end +end diff --git a/test/open_trip_planner_client/leg_test.exs b/test/open_trip_planner_client/leg_test.exs new file mode 100644 index 00000000..6f0733f4 --- /dev/null +++ b/test/open_trip_planner_client/leg_test.exs @@ -0,0 +1,29 @@ +defmodule OpenTripPlannerClient.LegTest do + use ExUnit.Case, async: true + import OpenTripPlannerClient.Leg + alias Test.Support.OpenTripPlannerClient, as: Support + + @from Support.random_stop() + @to Support.random_stop() + @start ~N[2017-01-01T00:00:00] + @stop ~N[2017-01-01T23:59:59] + + describe "same_leg?/1" do + test "same_legs are the same" do + leg = Support.transit_leg(@from, @to, @start, @stop) + assert same_leg?(leg, leg) + end + + test "same_legs with different steps are the same" do + leg = Support.personal_leg(@from, @to, @start, @stop) + modified_leg = %{leg | mode: %{leg.mode | steps: ["different personal steps"]}} + assert same_leg?(leg, modified_leg) + end + + test "legs with different to and from are different" do + leg = Support.personal_leg(@from, @to, @start, @stop) + different_leg = %{leg | from: %{leg.from | name: "New name"}} + refute same_leg?(leg, different_leg) + end + end +end diff --git a/test/open_trip_planner_client/params_builder_test.exs b/test/open_trip_planner_client/params_builder_test.exs new file mode 100644 index 00000000..69c9131f --- /dev/null +++ b/test/open_trip_planner_client/params_builder_test.exs @@ -0,0 +1,187 @@ +defmodule OpenTripPlannerClient.ParamsBuilderTest do + use ExUnit.Case, async: true + alias OpenTripPlannerClient.NamedPosition + import OpenTripPlannerClient.ParamsBuilder + + @from_inside %NamedPosition{ + latitude: 42.356365, + longitude: -71.060920 + } + + @to_inside %NamedPosition{ + latitude: 42.3636617, + longitude: -71.0832908 + } + + @from_stop %NamedPosition{ + name: "FromStop", + stop_id: "From_Id" + } + + @to_stop %NamedPosition{ + name: "ToStop", + stop_id: "To_Id" + } + + describe "build_params/1" do + test "depart_at sets date/time options" do + expected = + {:ok, + %{ + "date" => "\"2017-05-22\"", + "time" => "\"12:04pm\"", + "arriveBy" => "false", + "walkReluctance" => 15, + "transportModes" => "[{mode: WALK}, {mode: TRANSIT}]", + "fromPlace" => "\"::42.356365,-71.06092\"", + "locale" => "\"en\"", + "toPlace" => "\"::42.3636617,-71.0832908\"" + }} + + actual = + build_params( + @from_inside, + @to_inside, + depart_at: DateTime.from_naive!(~N[2017-05-22T16:04:20], "Etc/UTC") + ) + + assert expected == actual + end + + test "arrive_by sets date/time options" do + expected = + {:ok, + %{ + "date" => "\"2017-05-22\"", + "time" => "\"12:04pm\"", + "arriveBy" => "true", + "walkReluctance" => 15, + "transportModes" => "[{mode: WALK}, {mode: TRANSIT}]", + "fromPlace" => "\"::42.356365,-71.06092\"", + "locale" => "\"en\"", + "toPlace" => "\"::42.3636617,-71.0832908\"" + }} + + actual = + build_params( + @from_inside, + @to_inside, + arrive_by: DateTime.from_naive!(~N[2017-05-22T16:04:20], "Etc/UTC") + ) + + assert expected == actual + end + + test "wheelchair_accessible? sets wheelchair option" do + expected = + {:ok, + %{ + "wheelchair" => "true", + "walkReluctance" => 15, + "transportModes" => "[{mode: WALK}, {mode: TRANSIT}]", + "fromPlace" => "\"::42.356365,-71.06092\"", + "locale" => "\"en\"", + "toPlace" => "\"::42.3636617,-71.0832908\"" + }} + + actual = build_params(@from_inside, @to_inside, wheelchair_accessible?: true) + assert expected == actual + + expected = + {:ok, + %{ + "walkReluctance" => 15, + "transportModes" => "[{mode: WALK}, {mode: TRANSIT}]", + "fromPlace" => "\"::42.356365,-71.06092\"", + "locale" => "\"en\"", + "toPlace" => "\"::42.3636617,-71.0832908\"" + }} + + actual = build_params(@from_inside, @to_inside, wheelchair_accessible?: false) + assert expected == actual + end + + test ":mode defaults TRANSIT,WALK" do + expected = + {:ok, + %{ + "walkReluctance" => 15, + "transportModes" => "[{mode: WALK}, {mode: TRANSIT}]", + "fromPlace" => "\"::42.356365,-71.06092\"", + "locale" => "\"en\"", + "toPlace" => "\"::42.3636617,-71.0832908\"" + }} + + actual = build_params(@from_inside, @to_inside, mode: []) + assert expected == actual + end + + test ":mode builds a comma-separated list of modes to use" do + expected = + {:ok, + %{ + "walkReluctance" => 15, + "transportModes" => "[{mode: BUS}, {mode: SUBWAY}, {mode: TRAM}, {mode: WALK}]", + "fromPlace" => "\"::42.356365,-71.06092\"", + "locale" => "\"en\"", + "toPlace" => "\"::42.3636617,-71.0832908\"" + }} + + actual = build_params(@from_inside, @to_inside, mode: ["BUS", "SUBWAY", "TRAM"]) + assert expected == actual + end + + test "optimize_for: :less_walking sets walkReluctance value" do + expected = + {:ok, + %{ + "transportModes" => "[{mode: WALK}, {mode: TRANSIT}]", + "walkReluctance" => 27, + "fromPlace" => "\"::42.356365,-71.06092\"", + "locale" => "\"en\"", + "toPlace" => "\"::42.3636617,-71.0832908\"" + }} + + actual = build_params(@from_inside, @to_inside, optimize_for: :less_walking) + assert expected == actual + end + + test "optimize_for: :fewest_transfers sets transferPenalty value" do + expected = + {:ok, + %{ + "walkReluctance" => 15, + "transportModes" => "[{mode: WALK}, {mode: TRANSIT}]", + "transferPenalty" => 100, + "fromPlace" => "\"::42.356365,-71.06092\"", + "locale" => "\"en\"", + "toPlace" => "\"::42.3636617,-71.0832908\"" + }} + + actual = build_params(@from_inside, @to_inside, optimize_for: :fewest_transfers) + assert expected == actual + end + + test "bad options return an error" do + expected = {:error, {:bad_param, {:bad, :arg}}} + actual = build_params(@from_inside, @to_inside, bad: :arg) + assert expected == actual + end + + test "use stop id from to/from location" do + expected = { + :ok, + %{ + "fromPlace" => "\"FromStop::mbta-ma-us:From_Id\"", + "toPlace" => "\"ToStop::mbta-ma-us:To_Id\"", + "locale" => "\"en\"", + "transportModes" => "[{mode: WALK}, {mode: TRANSIT}]", + "walkReluctance" => 15 + } + } + + actual = build_params(@from_stop, @to_stop, []) + assert expected == actual + end + end +end diff --git a/test/open_trip_planner_client/parser_test.exs b/test/open_trip_planner_client/parser_test.exs new file mode 100644 index 00000000..88ef2f0a --- /dev/null +++ b/test/open_trip_planner_client/parser_test.exs @@ -0,0 +1,121 @@ +defmodule OpenTripPlannerClient.ParserTest do + use ExUnit.Case, async: true + import OpenTripPlannerClient.Parser + + alias OpenTripPlannerClient.{ + Itinerary, + NamedPosition, + PersonalDetail, + PersonalDetail.Step, + TransitDetail + } + + @fixture File.read!("test/fixture/north_station_to_park_plaza.json") + @parsed parse_ql(%{"data" => Jason.decode!(@fixture)}, false) + + describe "parse_ql/2" do + test "returns a list of Itinerary structs" do + {:ok, parsed} = @parsed + + for i <- parsed do + assert %Itinerary{} = i + end + + assert [first, _, _] = parsed + assert first.start == Timex.to_datetime(~N[2017-05-19T13:50:59], "America/New_York") + assert first.stop == Timex.to_datetime(~N[2017-05-19T14:03:19], "America/New_York") + end + + test "allows null absoluteDirection" do + pattern = "absoluteDirection\": \"SOUTH\"" + replacement = "absoluteDirection\": null" + json = String.replace(@fixture, pattern, replacement) + assert {:ok, _itinerary} = parse_ql(%{"data" => Jason.decode!(json)}, false) + end + + test "an itinerary has legs" do + {:ok, parsed} = @parsed + first = List.first(parsed) + [subway_leg, walk_leg] = first.legs + + assert %TransitDetail{ + route_id: "Orange", + trip_id: "33932853", + intermediate_stop_ids: ~w(70024 70022 70020 70018)s + } = subway_leg.mode + + assert "Orange Line" = subway_leg.long_name + assert "1" = subway_leg.type + assert "http://www.mbta.com" = subway_leg.url + assert "SUBWAY" = subway_leg.description + + assert %NamedPosition{stop_id: "70016"} = walk_leg.from + assert %NamedPosition{name: "Destination"} = walk_leg.to + assert is_binary(walk_leg.polyline) + assert %DateTime{} = walk_leg.start + assert %DateTime{} = walk_leg.stop + assert %PersonalDetail{} = walk_leg.mode + end + + test "positions can use stopId instead of stopCode" do + {:ok, parsed} = @parsed + stop_code_regex = ~r/"stopCode": "[^"]",/ + data = String.replace(@fixture, stop_code_regex, "") + {:ok, parsed_data} = parse_ql(%{"data" => Jason.decode!(data)}, false) + assert parsed_data == parsed + end + + test "walk legs have distance and step plans" do + {:ok, parsed} = @parsed + [_, walk_leg] = List.first(parsed).legs + assert walk_leg.mode.distance == 329.314 + + assert walk_leg.mode.steps == [ + %Step{ + distance: 138.02, + relative_direction: :depart, + absolute_direction: :south, + street_name: "Washington Street" + }, + %Step{ + distance: 111.909, + relative_direction: :right, + absolute_direction: :west, + street_name: "Oak Street West" + }, + %Step{ + distance: 79.385, + relative_direction: :continue, + absolute_direction: :west, + street_name: "Tremont Street" + } + ] + end + + test "subway legs have trip information" do + {:ok, parsed} = @parsed + [subway_leg, _] = List.first(parsed).legs + assert subway_leg.mode.route_id == "Orange" + assert subway_leg.mode.trip_id == "33932853" + assert subway_leg.mode.intermediate_stop_ids == ~w( + 70024 + 70022 + 70020 + 70018 + ) + end + + test "parses path_not_found error as location_not_accessible when accessiblity is checked" do + data = %{"plan" => %{"routingErrors" => [%{"code" => "PATH_NOT_FOUND"}]}} + + parsed_json = parse_ql(%{"data" => data}, true) + assert parsed_json == {:error, :location_not_accessible} + end + + test "parses path_not_found error as normally when accessibility is not checked" do + data = %{"plan" => %{"routingErrors" => [%{"code" => "PATH_NOT_FOUND"}]}} + parsed_json = parse_ql(%{"data" => data}, false) + assert parsed_json == {:error, :path_not_found} + end + end +end diff --git a/test/open_trip_planner_client_test.exs b/test/open_trip_planner_client_test.exs new file mode 100644 index 00000000..4bc49cee --- /dev/null +++ b/test/open_trip_planner_client_test.exs @@ -0,0 +1,18 @@ +defmodule OpenTripPlannerClientTest do + use ExUnit.Case, async: true + import OpenTripPlannerClient + alias OpenTripPlannerClient.NamedPosition + + describe "plan/3" do + test "bad options returns an error" do + expected = {:error, {:bad_param, {:bad, :arg}}} + + actual = + plan(%NamedPosition{latitude: 1, longitude: 1}, %NamedPosition{latitude: 2, longitude: 2}, + bad: :arg + ) + + assert expected == actual + end + end +end diff --git a/test/support/open_trip_planner_client.ex b/test/support/open_trip_planner_client.ex new file mode 100644 index 00000000..d74bac85 --- /dev/null +++ b/test/support/open_trip_planner_client.ex @@ -0,0 +1,123 @@ +defmodule Test.Support.OpenTripPlannerClient do + alias OpenTripPlannerClient.{Itinerary, Leg, NamedPosition, PersonalDetail, TransitDetail} + + @max_distance 1000 + @max_duration 30 * 60 + + @stops [ + %NamedPosition{ + name: "South Station", + stop_id: "place-sstat", + latitude: 42.352271, + longitude: -71.055242 + }, + %NamedPosition{ + name: "North Station", + stop_id: "place-north", + latitude: 42.365577, + longitude: -71.06129 + }, + %NamedPosition{ + name: "Back Bay", + stop_id: "place-bbsta", + latitude: 42.34735, + longitude: -71.075727 + }, + %NamedPosition{ + name: "Park Street", + stop_id: "place-pktrm", + latitude: 42.356395, + longitude: -71.062424 + }, + %NamedPosition{ + name: "Ruggles", + stop_id: "place-rugg", + latitude: 42.336377, + longitude: -71.088961 + }, + %NamedPosition{ + name: "Government Center", + stop_id: "place-gover", + latitude: 42.359705, + longitude: -71.059215 + } + ] + + @routes [ + %{route_id: "1", trip_id: "60168424", stop_id: "64"}, + %{route_id: "350", trip_id: "60144732", stop_id: "141"}, + %{route_id: "Blue", trip_id: "59736514", stop_id: "70038"}, + %{route_id: "Red", trip_id: "60392545", stop_id: "70105"} + ] + + def itinerary(from, to, opts \\ []) do + start = DateTime.utc_now() + duration = :rand.uniform(@max_duration) + stop = Timex.shift(start, seconds: duration) + midpoint_stop1 = random_stop([from.stop_id, to.stop_id]) + midpoint_time1 = Timex.shift(start, seconds: Integer.floor_div(duration, 3)) + midpoint_stop2 = random_stop([from.stop_id, to.stop_id, midpoint_stop1.stop_id]) + midpoint_time2 = Timex.shift(start, seconds: Integer.floor_div(duration, 3) * 2) + + %Itinerary{ + start: start, + stop: stop, + legs: [ + personal_leg(from, midpoint_stop1, start, midpoint_time1), + transit_leg(midpoint_stop1, midpoint_stop2, midpoint_time1, midpoint_time2), + personal_leg(midpoint_stop2, to, midpoint_time2, stop) + ], + accessible?: Keyword.get(opts, :wheelchair_accessible?, false) + } + end + + def random_stop(without_stop_ids \\ []) do + Enum.reject(@stops, &Enum.member?(without_stop_ids, &1.stop_id)) |> Enum.random() + end + + def personal_leg(from, to, start, stop) do + distance = :rand.uniform() * @max_distance + + %Leg{ + start: start, + stop: stop, + from: from, + to: to, + mode: %PersonalDetail{distance: distance, steps: [random_step(), random_step()]} + } + end + + def random_step do + distance = :rand.uniform() * @max_distance + absolute_direction = Enum.random(~w(north east south west)a) + relative_direction = Enum.random(~w(left right depart continue)a) + street_name = "Random Street" + + %PersonalDetail.Step{ + distance: distance, + relative_direction: relative_direction, + absolute_direction: absolute_direction, + street_name: street_name + } + end + + def transit_leg(from, to, start, stop) do + %{route_id: route_id, trip_id: trip_id, stop_id: stop_id} = Enum.random(@routes) + + %Leg{ + start: start, + stop: stop, + from: from, + to: to, + mode: %TransitDetail{ + route_id: route_id, + trip_id: trip_id, + intermediate_stop_ids: + Enum.random([ + [stop_id], + [] + ]) + } + } + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index ac4b717f..6fd49f19 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,3 @@ {:ok, _} = Application.ensure_all_started(:bypass) -ExUnit.start() +ExUnit.start(exclude: [:external])