diff --git a/assets/src/components/detours/diversionPage.tsx b/assets/src/components/detours/diversionPage.tsx index 6e07527d5..11227d5c8 100644 --- a/assets/src/components/detours/diversionPage.tsx +++ b/assets/src/components/detours/diversionPage.tsx @@ -3,9 +3,9 @@ import { DiversionPanel, DiversionPanelProps } from "./diversionPanel" import { DetourMap } from "./detourMap" import { Shape } from "../../schedule" import { useDetour } from "../../hooks/useDetour" +import { ListGroup } from "react-bootstrap" export const DiversionPage = ({ - directions, missedStops, routeName, routeDescription, @@ -22,6 +22,7 @@ export const DiversionPage = ({ waypoints, detourShape, + directions, canUndo, undoLastWaypoint, @@ -34,7 +35,15 @@ export const DiversionPage = ({
+ {directions?.map((d) => ( + + {d.instruction} + + ))} + + } missedStops={missedStops} routeName={routeName} routeDescription={routeDescription} diff --git a/assets/src/components/dummyDetourPage.tsx b/assets/src/components/dummyDetourPage.tsx index c8b3c014b..4b5f7faf8 100644 --- a/assets/src/components/dummyDetourPage.tsx +++ b/assets/src/components/dummyDetourPage.tsx @@ -1,28 +1,34 @@ import React, { useEffect, useState } from "react" -import { fetchShapeForRoute } from "../api" -import { Shape } from "../schedule" +import { fetchRoutePatterns } from "../api" +import { RoutePattern } from "../schedule" import { DiversionPage } from "./detours/diversionPage" +import { useRoute } from "../contexts/routesContext" export const DummyDetourPage = () => { - const [routeShape, setRouteShape] = useState(null) + const [routePattern, setRoutePattern] = useState(null) + + const routeNumber = "66" useEffect(() => { - fetchShapeForRoute("39").then((shapes) => { - setRouteShape(shapes[0]) + fetchRoutePatterns(routeNumber).then((routePatterns) => { + setRoutePattern(routePatterns[0]) }) }, []) + const route = useRoute(routePattern?.routeId) return ( -
- {routeShape && ( + <> + {routePattern && routePattern.shape && ( )} -
+ ) } diff --git a/assets/src/detour.d.ts b/assets/src/detour.ts similarity index 68% rename from assets/src/detour.d.ts rename to assets/src/detour.ts index 9f58a847f..e4629a399 100644 --- a/assets/src/detour.d.ts +++ b/assets/src/detour.ts @@ -2,4 +2,7 @@ import { ShapePoint } from "./schedule" export interface DetourShape { coordinates: ShapePoint[] + directions: { + instruction: string + }[] } diff --git a/assets/src/hooks/useDetour.tsx b/assets/src/hooks/useDetour.tsx index 74c9ae61a..d48b04760 100644 --- a/assets/src/hooks/useDetour.tsx +++ b/assets/src/hooks/useDetour.tsx @@ -1,9 +1,13 @@ import { useEffect, useMemo, useState } from "react" import { ShapePoint } from "../schedule" import { fetchDetourDirections } from "../api" +import { DetourShape } from "../detour" const useDetourDirections = (shapePoints: ShapePoint[]) => { const [detourShape, setDetourShape] = useState([]) + const [directions, setDirections] = useState< + DetourShape["directions"] | undefined + >(undefined) useEffect(() => { let shouldUpdate = true @@ -12,9 +16,10 @@ const useDetourDirections = (shapePoints: ShapePoint[]) => { return } - fetchDetourDirections(shapePoints).then((detourShape) => { - if (detourShape && shouldUpdate) { - setDetourShape(detourShape.coordinates) + fetchDetourDirections(shapePoints).then((detourInfo) => { + if (detourInfo && shouldUpdate) { + setDetourShape(detourInfo.coordinates) + setDirections(detourInfo.directions) } }) @@ -23,7 +28,10 @@ const useDetourDirections = (shapePoints: ShapePoint[]) => { } }, [shapePoints]) - return detourShape + return { + detourShape, + directions, + } } export const useDetour = () => { @@ -31,14 +39,14 @@ export const useDetour = () => { const [endPoint, setEndPoint] = useState(null) const [waypoints, setWaypoints] = useState([]) - const detourShape = useDetourDirections( + const { detourShape, directions } = useDetourDirections( useMemo( () => [startPoint, ...waypoints, endPoint].filter( (v): v is ShapePoint => !!v ), [startPoint, waypoints, endPoint] - ) + ) ?? [] ) const canAddWaypoint = () => startPoint !== null && endPoint === null @@ -92,6 +100,10 @@ export const useDetour = () => { * The routing API generated detour shape. */ detourShape, + /** + * The turn-by-turn directions generated by the routing API. + */ + directions, /** * Reports if {@link undoLastWaypoint} will do anything. diff --git a/assets/src/models/detourShapeData.ts b/assets/src/models/detourShapeData.ts index 748001055..d0593df4a 100644 --- a/assets/src/models/detourShapeData.ts +++ b/assets/src/models/detourShapeData.ts @@ -1,4 +1,4 @@ -import { array, Infer, number, type } from "superstruct" +import { array, Infer, number, string, type } from "superstruct" import { DetourShape } from "../detour" export const DetourShapeData = type({ @@ -8,6 +8,11 @@ export const DetourShapeData = type({ lon: number(), }) ), + directions: array( + type({ + instruction: string(), + }) + ), }) export type DetourShapeData = Infer diff --git a/assets/tests/factories/detourShapeFactory.ts b/assets/tests/factories/detourShapeFactory.ts new file mode 100644 index 000000000..128d9d3d2 --- /dev/null +++ b/assets/tests/factories/detourShapeFactory.ts @@ -0,0 +1,16 @@ +import { Factory } from "fishery" +import { DetourShape } from "../../src/detour" +import { shapePointFactory } from "./shapePointFactory" + +export const directionsFactory = Factory.define( + ({ sequence }) => { + return { + instruction: `directionInstruction${sequence}`, + } + } +) + +export const detourShapeFactory = Factory.define(() => ({ + coordinates: shapePointFactory.buildList(3), + directions: directionsFactory.buildList(3), +})) diff --git a/assets/tests/factories/shapePointFactory.ts b/assets/tests/factories/shapePointFactory.ts new file mode 100644 index 000000000..31244520f --- /dev/null +++ b/assets/tests/factories/shapePointFactory.ts @@ -0,0 +1,12 @@ +import { Factory } from "fishery" +import { localGeoCoordinateFactory } from "./geoCoordinate" +import { ShapePoint } from "../../src/schedule" + +/** + * Wrapper around {@link localGeoCoordinateFactory} until {@link ShapePoint} is + * updated to a global shared coordinate object + */ +export const shapePointFactory = Factory.define(() => { + const { latitude, longitude } = localGeoCoordinateFactory.build() + return { lat: latitude, lon: longitude } +}) diff --git a/assets/tests/hooks/useDetour.test.ts b/assets/tests/hooks/useDetour.test.ts index 0fdf91d94..b4d2331bf 100644 --- a/assets/tests/hooks/useDetour.test.ts +++ b/assets/tests/hooks/useDetour.test.ts @@ -3,6 +3,8 @@ import { fetchDetourDirections } from "../../src/api" import { renderHook, waitFor } from "@testing-library/react" import { useDetour } from "../../src/hooks/useDetour" import { act } from "react-dom/test-utils" +import { detourShapeFactory } from "../factories/detourShapeFactory" +import { ShapePoint } from "../../src/schedule" jest.mock("../../src/api") @@ -41,36 +43,41 @@ describe("useDetour", () => { act(() => result.current.addWaypoint({ lat: 0, lon: 0 })) - expect(result.current.waypoints).toEqual([]) + expect(result.current.waypoints).toHaveLength(0) }) test("when `endPoint` is set, `addWaypoint` does nothing", () => { - const start = { lat: 0, lon: 0 } - const end = { lat: 1, lon: 1 } - const { result } = renderHook(useDetour) expect(result.current.startPoint).toBeNull() - act(() => result.current.addConnectionPoint(start)) - act(() => result.current.addConnectionPoint(end)) + act(() => result.current.addConnectionPoint({ lat: 0, lon: 0 })) + act(() => result.current.addConnectionPoint({ lat: 1, lon: 1 })) act(() => result.current.addWaypoint({ lat: 0, lon: 0 })) - expect(result.current.waypoints).toEqual([]) + expect(result.current.waypoints).toHaveLength(0) }) - test("when `addWaypoint` is called, `detourShape` is updated", async () => { - const start = { lat: 0, lon: 0 } - const end = { lat: 1, lon: 1 } - const apiResult = [ - { lat: -1, lon: -1 }, - { lat: -2, lon: -2 }, - ] + test("when `addWaypoint` is called, should update `detourShape` and `directions`", async () => { + const start: ShapePoint = { lat: -2, lon: -2 } + const end: ShapePoint = { lat: -1, lon: -1 } + + const detourShape = detourShapeFactory.build({ + coordinates: [ + { lat: 0, lon: 0 }, + { lat: 1, lon: 1 }, + { lat: 2, lon: 2 }, + ], + directions: [ + { instruction: "Turn Left onto Main St" }, + { instruction: "Turn Right onto High St" }, + ], + }) jest.mocked(fetchDetourDirections).mockImplementation((coordinates) => { expect(coordinates).toStrictEqual([start, end]) - return Promise.resolve({ coordinates: apiResult }) + return Promise.resolve(detourShape) }) const { result } = renderHook(useDetour) @@ -80,14 +87,16 @@ describe("useDetour", () => { expect(result.current.startPoint).toBe(start) + expect(jest.mocked(fetchDetourDirections)).toHaveBeenCalledTimes(1) expect(jest.mocked(fetchDetourDirections)).toHaveBeenNthCalledWith(1, [ start, end, ]) - await waitFor(() => - expect(result.current.detourShape).toStrictEqual(apiResult) - ) + await waitFor(() => { + expect(result.current.detourShape).toStrictEqual(detourShape.coordinates) + expect(result.current.directions).toStrictEqual(detourShape.directions) + }) }) test("when `undoLastWaypoint` is called, removes the last `waypoint`", async () => { @@ -103,7 +112,7 @@ describe("useDetour", () => { act(() => result.current.undoLastWaypoint()) - expect(result.current.waypoints).toStrictEqual([]) + expect(result.current.waypoints).toHaveLength(0) }) test("when `undoLastWaypoint` is called, should call API with updated waypoints", async () => { @@ -130,7 +139,7 @@ describe("useDetour", () => { act(() => result.current.addConnectionPoint({ lat: 0, lon: 0 })) - expect(result.current.waypoints).toStrictEqual([]) + expect(result.current.waypoints).toHaveLength(0) expect(result.current.canUndo).toBe(false) }) @@ -140,6 +149,7 @@ describe("useDetour", () => { act(() => result.current.addConnectionPoint({ lat: 0, lon: 0 })) act(() => result.current.addWaypoint({ lat: 1, lon: 1 })) + expect(result.current.waypoints).not.toHaveLength(0) expect(result.current.canUndo).toBe(true) }) @@ -149,6 +159,7 @@ describe("useDetour", () => { act(() => result.current.addConnectionPoint({ lat: 0, lon: 0 })) act(() => result.current.addConnectionPoint({ lat: 0, lon: 0 })) + expect(result.current.endPoint).not.toBeNull() expect(result.current.canUndo).toBe(false) }) }) diff --git a/lib/skate/open_route_service_api.ex b/lib/skate/open_route_service_api.ex index 5290fa919..0b16f92a3 100644 --- a/lib/skate/open_route_service_api.ex +++ b/lib/skate/open_route_service_api.ex @@ -21,6 +21,14 @@ defmodule Skate.OpenRouteServiceAPI do %{"lat" => 0, "lon" => 0}, %{"lat" => 0.5, "lon" => 0.1}, %{"lat" => 1, "lon" => 0} + ], + directions: [ + %{ + instruction: "Turn right onto 1st Avenue" + }, + %{ + instruction: "Turn left onto 2nd Place" + } ] } } @@ -30,10 +38,10 @@ defmodule Skate.OpenRouteServiceAPI do ## Examples iex> Skate.OpenRouteServiceAPI.directions([]) - {:ok, %Skate.OpenRouteServiceAPI.DirectionsResponse{coordinates: []}} + {:ok, %Skate.OpenRouteServiceAPI.DirectionsResponse{coordinates: [], directions: []}} iex> Skate.OpenRouteServiceAPI.directions([%{"lat" => 0, "lon" => 0}]) - {:ok, %Skate.OpenRouteServiceAPI.DirectionsResponse{coordinates: []}} + {:ok, %Skate.OpenRouteServiceAPI.DirectionsResponse{coordinates: [], directions: []}} If anything goes wrong, then this returns an error instead. @@ -63,13 +71,49 @@ defmodule Skate.OpenRouteServiceAPI do end defp parse_directions(payload) do - %{"features" => [%{"geometry" => %{"coordinates" => coordinates}}]} = payload + %{ + "features" => [ + %{ + "geometry" => %{"coordinates" => coordinates}, + "properties" => %{"segments" => segments} + } + ] + } = payload {:ok, %DirectionsResponse{ - coordinates: Enum.map(coordinates, fn [lon, lat] -> %{"lat" => lat, "lon" => lon} end) + coordinates: Enum.map(coordinates, fn [lon, lat] -> %{"lat" => lat, "lon" => lon} end), + directions: + segments + |> Enum.flat_map(& &1["steps"]) + |> Enum.filter(fn %{"type" => type} -> map_type(type) not in [:goal, :error] end) + |> Enum.map( + &%{ + instruction: &1["instruction"] + } + ) }} end defp client(), do: Application.get_env(:skate, Skate.OpenRouteServiceAPI)[:client] + + defp map_type(type_id) do + case type_id do + 0 -> :left + 1 -> :right + 2 -> :sharp_left + 3 -> :sharp_right + 4 -> :slight_left + 5 -> :slight_right + 6 -> :straight + 7 -> :enter_roundabout + 8 -> :exit_roundabout + 9 -> :u_turn + 10 -> :goal + 11 -> :depart + 12 -> :keep_left + 13 -> :keep_right + _ -> :error + end + end end diff --git a/lib/skate/open_route_service_api/directions_response.ex b/lib/skate/open_route_service_api/directions_response.ex index eec5bfcc2..d7c3a410c 100644 --- a/lib/skate/open_route_service_api/directions_response.ex +++ b/lib/skate/open_route_service_api/directions_response.ex @@ -8,8 +8,31 @@ defmodule Skate.OpenRouteServiceAPI.DirectionsResponse do Type that represents a request made to OpenRouteService's Directions API """ @type t() :: %__MODULE__{ - coordinates: [[float()]] + coordinates: [[float()]], + directions: [ + %{ + instruction: binary(), + name: binary(), + type: direction_t() + } + ] } - defstruct coordinates: [] + @type direction_t() :: + :left + | :right + | :sharp_left + | :sharp_right + | :slight_left + | :slight_right + | :straight + | :enter_roundabout + | :exit_roundabout + | :u_turn + | :goal + | :depart + | :keep_left + | :keep_right + + defstruct coordinates: [], directions: [] end diff --git a/test/skate/open_route_service_api_test.exs b/test/skate/open_route_service_api_test.exs index 2a8ec2da9..62714f583 100644 --- a/test/skate/open_route_service_api_test.exs +++ b/test/skate/open_route_service_api_test.exs @@ -25,6 +25,33 @@ defmodule Skate.OpenRouteServiceAPITest do [0.1, 0.5], [0, 1] ] + }, + "properties" => %{ + "segments" => [ + %{ + "steps" => [ + %{ + "instruction" => "Turn right onto 1st Avenue", + "name" => "1st Avenue", + "type" => 1 + } + ] + }, + %{ + "steps" => [ + %{ + "instruction" => "Turn left onto 2nd Place", + "name" => "2nd Place", + "type" => 0 + }, + %{ + "instruction" => "Arrive", + "name" => "-", + "type" => 10 + } + ] + } + ] } } ] diff --git a/test/skate_web/controllers/detour_route_controller_test.exs b/test/skate_web/controllers/detour_route_controller_test.exs index cabf1fa22..4f7b29cb5 100644 --- a/test/skate_web/controllers/detour_route_controller_test.exs +++ b/test/skate_web/controllers/detour_route_controller_test.exs @@ -3,6 +3,7 @@ defmodule SkateWeb.DetourRouteControllerTest do use SkateWeb.ConnCase import Mox + import Skate.Factory setup :verify_on_exit! @@ -11,14 +12,10 @@ defmodule SkateWeb.DetourRouteControllerTest do end describe "directions" do - defp directions_json(coordinates: coordinates) do - %{"features" => [%{"geometry" => %{"coordinates" => coordinates}}]} - end - @tag :authenticated test "returns shape data as geojson", %{conn: conn} do expect(Skate.OpenRouteServiceAPI.MockClient, :get_directions, fn _ -> - {:ok, directions_json(coordinates: [[0, 0], [0.5, 0.5], [1, 1]])} + {:ok, build(:ors_directions_json, coordinates: [[0, 0], [0.5, 0.5], [1, 1]])} end) conn = @@ -38,12 +35,59 @@ defmodule SkateWeb.DetourRouteControllerTest do json_response(conn, 200) end + @tag :authenticated + test "returns directions as a flat list", %{conn: conn} do + expect(Skate.OpenRouteServiceAPI.MockClient, :get_directions, fn _ -> + {:ok, + build(:ors_directions_json, + segments: [ + %{ + "steps" => [ + %{ + "instruction" => "1", + "type" => 1 + }, + %{ + "instruction" => "2", + "type" => 0 + } + ] + }, + %{ + "steps" => [ + %{ + "instruction" => "3", + "type" => 2 + } + ] + } + ] + )} + end) + + conn = + post(conn, ~p"/api/detours/directions", + coordinates: [%{"lat" => 0, "lon" => 0}, %{"lat" => 1, "lon" => 1}] + ) + + assert %{ + "data" => %{ + "directions" => [ + %{"instruction" => "1"}, + %{"instruction" => "2"}, + %{"instruction" => "3"} + ] + } + } = + json_response(conn, 200) + end + @tag :authenticated test "formats input coordinates as [lon, lat]", %{conn: conn} do expect(Skate.OpenRouteServiceAPI.MockClient, :get_directions, fn request -> assert %DirectionsRequest{coordinates: [[100, 1], [101, 2]]} = request - {:ok, directions_json(coordinates: [[0, 0], [0.5, 0.5], [1, 1]])} + {:ok, build(:ors_directions_json, coordinates: [[0, 0], [0.5, 0.5], [1, 1]])} end) post(conn, ~p"/api/detours/directions", @@ -54,7 +98,7 @@ defmodule SkateWeb.DetourRouteControllerTest do @tag :authenticated test "interprets output coordinates as [lon, lat]", %{conn: conn} do expect(Skate.OpenRouteServiceAPI.MockClient, :get_directions, fn _ -> - {:ok, directions_json(coordinates: [[100, 0], [101, 1]])} + {:ok, build(:ors_directions_json, coordinates: [[100, 0], [101, 1]])} end) conn = @@ -80,7 +124,8 @@ defmodule SkateWeb.DetourRouteControllerTest do assert %{ "data" => %{ - "coordinates" => [] + "coordinates" => [], + "directions" => [] } } = json_response(conn, 200) @@ -93,7 +138,8 @@ defmodule SkateWeb.DetourRouteControllerTest do assert %{ "data" => %{ - "coordinates" => [] + "coordinates" => [], + "directions" => [] } } = json_response(conn, 200) diff --git a/test/support/factory.ex b/test/support/factory.ex index 46cd3ee64..04a1b09fb 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -297,4 +297,42 @@ defmodule Skate.Factory do username: sequence("test_user") } end + + def ors_directions_step_json_factory do + %{ + "instruction" => sequence("ors_instruction_step"), + "name" => sequence("ors_instruction_name"), + "type" => sequence("ors_instruction_type", [0, 1, 2, 3, 4, 5, 6, 7, 12, 13]) + } + end + + def ors_directions_segment_json_factory do + %{ + "steps" => + build_list( + sequence("ors_segment_json_num_steps", [4, 1, 3, 2]), + :ors_directions_step_json + ) + } + end + + def ors_directions_json_factory(attrs) do + coordinates = Map.get(attrs, :coordinates, [[0, 0], [1, 1], [2, 2]]) + + segments = + Map.get_lazy(attrs, :segments, fn -> + build_list(3, :ors_directions_segment_json) + end) + + %{ + "features" => [ + %{ + "geometry" => %{"coordinates" => coordinates}, + "properties" => %{ + "segments" => segments + } + } + ] + } + end end