Say what a transit vehicle is doing, and scope a stop's route lines

b0f58c2 · Gabriel Morell · 2026-08-10 22:49

24 files +506 -15
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+20 −9
@@ -685,16 +685,27 @@ class LeafletMap extends HTMLElement {
685 685 </div>
686 686 `;
687 687
688 − // Add type-specific content
688 + // Only rows we actually have. This used to print the vehicle-id and
689 + // route-id attributes, which nothing set, so every transit popup read
690 + // "Vehicle: null".
691 + const row = (label, value) =>
692 + value
693 + ? `<div class="text-xs text-gray-600 mb-1"><strong>${label}:</strong> ${value}</div>`
694 + : '';
695 +
689 696 if (type === 'vehicle') {
690 − const vehicleId = markerEl.getAttribute('vehicle-id');
691 − const routeId = markerEl.getAttribute('route-id');
692 − content += `
693 − <div class="text-xs text-gray-600 mb-1">
694 − <strong>Vehicle:</strong> ${vehicleId}
695 − </div>
696 − ${routeId ? `<div class="text-xs text-gray-600 mb-1"><strong>Route:</strong> ${routeId}</div>` : ''}
697 − `;
697 + content +=
698 + row('Route', markerEl.getAttribute('route')) +
699 + row('To', markerEl.getAttribute('dest')) +
700 + row('Direction', markerEl.getAttribute('direction')) +
701 + row('Mode', markerEl.getAttribute('mode')) +
702 + row('Vehicle', markerEl.getAttribute('vehicle-id'));
703 + }
704 +
705 + if (type === 'aircraft') {
706 + content +=
707 + row('Class', markerEl.getAttribute('aircraft-class')) +
708 + row('Heading', markerEl.getAttribute('bearing'));
698 709 }
699 710
700 711 content += `</div>`;
modified apps/room_sanctum/lib/room_sanctum/storage.ex
+112 −0
@@ -378,6 +378,118 @@ defmodule RoomSanctum.Storage do
378 378 |> Repo.all()
379 379 end
380 380
381 + @doc """
382 + Attach route, destination, direction and mode to realtime vehicle positions.
383 +
384 + A position names a route and a trip and nothing else, so everything a marker
385 + says comes from the static feed -- looked up only for the trips currently on
386 + screen, which is tens of rows rather than the whole table.
387 + """
388 + def with_trip_context([], _source_id), do: []
389 +
390 + def with_trip_context(vehicles, source_id) do
391 + by_trip =
392 + vehicles
393 + |> Enum.map(&Map.get(&1, :trip_id))
394 + |> Enum.reject(&is_nil/1)
395 + |> then(&vehicle_context(source_id, &1))
396 +
397 + # Realtime feeds run trips the schedule does not have -- shuttles and
398 + # added trips -- but they still name a route the schedule knows. On MBTA
399 + # that is about one vehicle in eight, which would otherwise show nothing
400 + # at all rather than at least which route it is on.
401 + by_route =
402 + vehicles
403 + |> Enum.reject(&Map.has_key?(by_trip, Map.get(&1, :trip_id)))
404 + |> Enum.map(&Map.get(&1, :route_id))
405 + |> Enum.reject(&is_nil/1)
406 + |> then(&route_context(source_id, &1))
407 +
408 + Enum.map(vehicles, fn vehicle ->
409 + context =
410 + Map.get(by_trip, Map.get(vehicle, :trip_id)) ||
411 + Map.get(by_route, Map.get(vehicle, :route_id)) ||
412 + %{}
413 +
414 + Map.merge(vehicle, context)
415 + end)
416 + end
417 +
418 + @doc """
419 + Route name and mode by route_id, for vehicles whose trip is not in the feed.
420 + """
421 + def route_context(_source_id, []), do: %{}
422 +
423 + def route_context(source_id, route_ids) do
424 + Repo.query!(
425 + """
426 + SELECT r.route_id,
427 + COALESCE(NULLIF(BTRIM(r.route_short_name), ''), r.route_long_name, r.route_id),
428 + r.route_type
429 + FROM gtfs_routes r
430 + WHERE r.source_id = $1 AND r.route_id = ANY($2)
431 + """,
432 + [source_id, Enum.uniq(route_ids)]
433 + ).rows
434 + |> Map.new(fn [route_id, route, route_type] ->
435 + {route_id, %{route: route, dest: nil, direction: nil, mode: mode_label(route_type)}}
436 + end)
437 + end
438 +
439 + @doc """
440 + Context for the trips a set of vehicles is running, keyed by trip_id:
441 + `%{route:, dest:, direction:, mode:}`.
442 +
443 + A realtime vehicle position carries only ids -- the route it is on and the
444 + trip it is running -- so everything a human wants to read comes from the
445 + static feed.
446 + """
447 + def vehicle_context(_source_id, []), do: %{}
448 +
449 + def vehicle_context(source_id, trip_ids) do
450 + Repo.query!(
451 + """
452 + SELECT t.trip_id,
453 + COALESCE(NULLIF(BTRIM(r.route_short_name), ''), r.route_long_name, r.route_id),
454 + NULLIF(BTRIM(t.trip_headsign), ''),
455 + d.direction,
456 + r.route_type
457 + FROM gtfs_trips t
458 + JOIN gtfs_routes r ON r.source_id = t.source_id AND r.route_id = t.route_id
459 + LEFT JOIN gtfs_directions d
460 + ON d.source_id = t.source_id
461 + AND d.route_id = t.route_id
462 + AND d.direction_id = t.direction_id
463 + WHERE t.source_id = $1 AND t.trip_id = ANY($2)
464 + """,
465 + [source_id, Enum.uniq(trip_ids)]
466 + ).rows
467 + |> Map.new(fn [trip_id, route, dest, direction, route_type] ->
468 + {trip_id,
469 + %{route: route, dest: dest, direction: direction, mode: mode_label(route_type)}}
470 + end)
471 + end
472 +
473 + # GTFS route_type as something worth showing a person. Anything outside the
474 + # spec's list is left alone rather than guessed at.
475 + defp mode_label(nil), do: nil
476 +
477 + defp mode_label(route_type) do
478 + case to_string(route_type) do
479 + "0" -> "Tram"
480 + "1" -> "Subway"
481 + "2" -> "Rail"
482 + "3" -> "Bus"
483 + "4" -> "Ferry"
484 + "5" -> "Cable tram"
485 + "6" -> "Aerial lift"
486 + "7" -> "Funicular"
487 + "11" -> "Trolleybus"
488 + "12" -> "Monorail"
489 + other -> other
490 + end
491 + end
492 +
381 493 @doc """
382 494 The routes that call at a stop, as a list of route_ids.
383 495
modified apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+26 −0
@@ -214,6 +214,11 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
214 214 tint={Map.get(p, :tint)}
215 215 bearing={Map.get(p, :bearing)}
216 216 route-type={Map.get(p, :route_type)}
217 + route={Map.get(p, :route)}
218 + dest={Map.get(p, :dest)}
219 + direction={Map.get(p, :direction)}
220 + mode={Map.get(p, :mode)}
221 + vehicle-id={Map.get(p, :vehicle_id)}
217 222 aircraft-class={Map.get(p, :aircraft_class)}
218 223 id={"#{@id}-marker-#{p.id}"}
219 224 data-has-query={
@@ -627,6 +632,13 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
627 632 lng: vehicle.longitude,
628 633 bearing: vehicle.bearing,
629 634 route_type: Map.get(route_types, vehicle.route_id),
635 + # Filled in from the static feed by the caller; a vehicle whose trip is
636 + # not in the schedule keeps its ids and says so rather than inventing.
637 + route: Map.get(vehicle, :route),
638 + dest: Map.get(vehicle, :dest),
639 + direction: Map.get(vehicle, :direction),
640 + mode: Map.get(vehicle, :mode),
641 + name: vehicle_label(vehicle),
630 642 timestamp: vehicle.timestamp,
631 643 icon: "fa-bus" # or different icon based on vehicle type
632 644 }
@@ -658,6 +670,20 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
658 670 """
659 671 def aircraft_from_preview_list(preview), do: aircraft_from_preview(:icarus, preview)
660 672
673 + # "38 to Geary + 33rd Avenue" where the schedule knows the trip, otherwise
674 + # whatever identifier the feed did give us.
675 + defp vehicle_label(vehicle) do
676 + route = Map.get(vehicle, :route)
677 + dest = Map.get(vehicle, :dest)
678 +
679 + case {route, dest} do
680 + {nil, nil} -> vehicle.vehicle_id || vehicle.route_id || "Vehicle"
681 + {route, nil} -> route
682 + {nil, dest} -> dest
683 + {route, dest} -> "#{route} to #{dest}"
684 + end
685 + end
686 +
661 687 # ADS-B aircraft. Positions are string-keyed because that is how they leave
662 688 # the adsb.fi payload, and the callsign is the useful label -- registration
663 689 # or the ICAO address only when the flight is not transmitting one.
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.ex
+4 −1
@@ -318,7 +318,10 @@ defmodule RoomSanctumWeb.QueryLive.Index do
318 318 # IO.inspect("Fetching vehicles from source #{source_id}")
319 319 try do
320 320 case RoomGtfs.Worker.get_current_vehicle_positions(source_id) do
321 − vehicles when is_list(vehicles) ->
321 + vehicles when is_list(vehicles) ->
322 + # Per source: a trip_id only means anything within its own feed.
323 + vehicles = RoomSanctum.Storage.with_trip_context(vehicles, source_id)
324 +
322 325 # IO.inspect("Source #{source_id} returned #{length(vehicles)} vehicles")
323 326 vehicles
324 327 other ->
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+46 −3
@@ -32,7 +32,7 @@ defmodule RoomSanctumWeb.QueryLive.Show do
32 32
33 33 lines =
34 34 case {showing?, socket.assigns.route_lines} do
35 − {true, []} -> RoomSanctum.Storage.list_route_lines([socket.assigns.query.source_id])
35 + {true, []} -> route_lines_for(socket.assigns.query)
36 36 {_, existing} -> existing
37 37 end
38 38
@@ -42,6 +42,40 @@ defmodule RoomSanctumWeb.QueryLive.Show do
42 42 |> assign(:route_lines, lines)}
43 43 end
44 44
45 + # A stop query is about one stop, so drawing every shape in the feed is
46 + # useless -- MBTA alone is hundreds of routes. Only the routes that call
47 + # there get drawn; a query that is not about a stop keeps the whole set,
48 + # since there is nothing narrower to mean.
49 + defp route_lines_for(%{source_id: source_id} = query) do
50 + lines = RoomSanctum.Storage.list_route_lines([source_id])
51 +
52 + case stop_id_of(query) do
53 + nil ->
54 + lines
55 +
56 + stop_id ->
57 + serving =
58 + source_id
59 + |> RoomSanctum.Storage.routes_serving_stop(stop_id)
60 + |> MapSet.new(&"#{source_id}-#{&1}")
61 +
62 + Enum.filter(lines, &MapSet.member?(serving, &1.id))
63 + end
64 + end
65 +
66 + # GTFS stop queries key on :stop, GBFS on :stop_id.
67 + defp stop_id_of(%{query: nil}), do: nil
68 +
69 + defp stop_id_of(%{query: q}) do
70 + case Map.get(q, :stop) || Map.get(q, :stop_id) do
71 + value when is_binary(value) -> value
72 + value when is_integer(value) -> to_string(value)
73 + _ -> nil
74 + end
75 + end
76 +
77 + defp stop_id_of(_), do: nil
78 +
45 79 @impl true
46 80 def handle_params(%{"id" => id}, _, socket) do
47 81 q = Configuration.get_query!(id)
@@ -176,13 +210,22 @@ defmodule RoomSanctumWeb.QueryLive.Show do
176 210 # Filter vehicles to only show those relevant to current query
177 211 filtered_vehicles = filter_vehicles_for_single_query(vehicles, socket.assigns.query)
178 212 # IO.inspect("Query Show: Filtered #{length(vehicles)} to #{length(filtered_vehicles)} vehicles for stop")
179 − {:noreply, socket |> assign(:vehicle_positions, filtered_vehicles)}
213 + {:noreply,
214 + socket
215 + |> assign(
216 + :vehicle_positions,
217 + RoomSanctum.Storage.with_trip_context(filtered_vehicles, socket.assigns.query.source_id)
218 + )}
180 219 end
181 220
182 221 def handle_info(:update_vehicle_positions, socket) do
183 222 # Fetch and filter vehicle positions for this specific query
184 223 if socket.assigns.query.source.type == :gtfs do
185 − vehicles = get_vehicle_positions_for_query(socket.assigns.query)
224 + vehicles =
225 + socket.assigns.query
226 + |> get_vehicle_positions_for_query()
227 + |> RoomSanctum.Storage.with_trip_context(socket.assigns.query.source_id)
228 +
186 229 {:noreply, socket |> assign(:vehicle_positions, vehicles)}
187 230 else
188 231 {:noreply, socket}
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+6 −2
@@ -1007,7 +1007,9 @@ defmodule RoomSanctumWeb.SourceLive.Show do
1007 1007 @impl true
1008 1008 def handle_info({:vehicle_positions_updated, source_id, vehicles}, socket) do
1009 1009 if socket.assigns.source_id == source_id do
1010 − {:noreply, socket |> assign(:vehicle_positions, vehicles)}
1010 + {:noreply,
1011 + socket
1012 + |> assign(:vehicle_positions, Storage.with_trip_context(vehicles, socket.assigns.source_id))}
1011 1013 else
1012 1014 {:noreply, socket}
1013 1015 end
@@ -1055,7 +1057,9 @@ defmodule RoomSanctumWeb.SourceLive.Show do
1055 1057 case socket.assigns.source.type do
1056 1058 :gtfs ->
1057 1059 vehicles = RoomGtfs.Worker.get_current_vehicle_positions(socket.assigns.source_id)
1058 − {:noreply, socket |> assign(:vehicle_positions, vehicles)}
1060 + {:noreply,
1061 + socket
1062 + |> assign(:vehicle_positions, Storage.with_trip_context(vehicles, socket.assigns.source_id))}
1059 1063 _ ->
1060 1064 {:noreply, socket}
1061 1065 end
added apps/room_sanctum/test/room_sanctum_web/components/vehicle_popup_test.exs
+180 −0
@@ -0,0 +1,180 @@
1 +defmodule RoomSanctumWeb.VehiclePopupTest do
2 + @moduledoc """
3 + A realtime vehicle position carries only ids, so what the marker says has to
4 + come from the static feed.
5 + """
6 + use RoomSanctumWeb.ConnCase, async: true
7 +
8 + import Phoenix.LiveViewTest
9 + import RoomSanctumWeb.Components.QueryGeospatialMap
10 +
11 + alias RoomSanctum.{Accounts, Configuration, Repo, Storage}
12 + alias RoomSanctum.Storage.GTFS.{Direction, Route, Trip}
13 +
14 + setup do
15 + {:ok, user} =
16 + Accounts.register_user(%{
17 + email: "veh#{System.unique_integer([:positive])}@example.com",
18 + password: "hello world!hello world!"
19 + })
20 +
21 + {:ok, source} =
22 + Configuration.create_source(%{
23 + name: "Muni", notes: "", type: :gtfs, enabled: true, user_id: user.id,
24 + config: %{"__type__" => "gtfs", "url" => "https://example.test/g.zip", "tz" => "UTC"}
25 + })
26 +
27 + Repo.insert!(%Route{source_id: source.id, route_id: "R38", route_type: "3",
28 + route_short_name: "38", route_long_name: "Geary"})
29 + Repo.insert!(%Trip{source_id: source.id, trip_id: "T1", route_id: "R38",
30 + direction_id: 0, trip_headsign: "Geary + 33rd Avenue", service_id: "s"})
31 + Repo.insert!(%Direction{source_id: source.id, route_id: "R38", direction_id: 0,
32 + direction: "Outbound"})
33 +
34 + %{sid: source.id}
35 + end
36 +
37 + defp vehicle(attrs \\ %{}) do
38 + Map.merge(
39 + %{vehicle_id: "V1", trip_id: "T1", route_id: "R38",
40 + latitude: 37.78, longitude: -122.44, bearing: 90.0, timestamp: nil},
41 + attrs
42 + )
43 + end
44 +
45 + describe "looking up what a vehicle is doing" do
46 + test "resolves route, destination, direction and mode", %{sid: sid} do
47 + assert %{"T1" => ctx} = Storage.vehicle_context(sid, ["T1"])
48 +
49 + assert ctx.route == "38"
50 + assert ctx.dest == "Geary + 33rd Avenue"
51 + assert ctx.direction == "Outbound"
52 + assert ctx.mode == "Bus"
53 + end
54 +
55 + test "falls back to the long name when there is no short one", %{sid: sid} do
56 + Repo.insert!(%Route{source_id: sid, route_id: "R2", route_type: "1",
57 + route_short_name: " ", route_long_name: "Market Street Subway"})
58 + Repo.insert!(%Trip{source_id: sid, trip_id: "T2", route_id: "R2", service_id: "s"})
59 +
60 + assert Storage.vehicle_context(sid, ["T2"])["T2"].route == "Market Street Subway"
61 + end
62 +
63 + test "a trip with no direction row still resolves the rest", %{sid: sid} do
64 + Repo.insert!(%Route{source_id: sid, route_id: "R9", route_type: "4",
65 + route_short_name: "F", route_long_name: "Ferry"})
66 + Repo.insert!(%Trip{source_id: sid, trip_id: "T9", route_id: "R9",
67 + direction_id: 1, trip_headsign: "Sausalito", service_id: "s"})
68 +
69 + ctx = Storage.vehicle_context(sid, ["T9"])["T9"]
70 +
71 + assert ctx.direction == nil
72 + assert ctx.dest == "Sausalito"
73 + assert ctx.mode == "Ferry"
74 + end
75 +
76 + test "an unknown trip is simply absent", %{sid: sid} do
77 + assert Storage.vehicle_context(sid, ["nope"]) == %{}
78 + end
79 +
80 + test "no trips means no query", %{sid: sid} do
81 + assert Storage.vehicle_context(sid, []) == %{}
82 + end
83 +
84 + test "does not reach into another feed", %{sid: sid} do
85 + assert Storage.vehicle_context(sid + 9999, ["T1"]) == %{}
86 + end
87 + end
88 +
89 + describe "vehicles running a trip the schedule does not have" do
90 + test "still say which route and mode, from the route alone", %{sid: sid} do
91 + # shuttles and added trips: MBTA runs about one vehicle in eight this way
92 + vehicles = [vehicle(%{vehicle_id: "shuttle", trip_id: "ADDED-1", route_id: "R38"})]
93 +
94 + [enriched] = Storage.with_trip_context(vehicles, sid)
95 +
96 + assert enriched.route == "38"
97 + assert enriched.mode == "Bus"
98 + # the schedule is what knows these, and it does not know this trip
99 + assert enriched.dest == nil
100 + assert enriched.direction == nil
101 + end
102 +
103 + test "a vehicle on an unknown route is left as it came", %{sid: sid} do
104 + vehicles = [vehicle(%{trip_id: "ADDED-2", route_id: "NOPE"})]
105 +
106 + [enriched] = Storage.with_trip_context(vehicles, sid)
107 +
108 + refute Map.has_key?(enriched, :route)
109 + assert enriched.vehicle_id == "V1"
110 + end
111 +
112 + test "scheduled and unscheduled vehicles are enriched in one pass", %{sid: sid} do
113 + vehicles = [
114 + vehicle(%{vehicle_id: "scheduled", trip_id: "T1"}),
115 + vehicle(%{vehicle_id: "added", trip_id: "ADDED-3", route_id: "R38"})
116 + ]
117 +
118 + [scheduled, added] = Storage.with_trip_context(vehicles, sid)
119 +
120 + assert scheduled.dest == "Geary + 33rd Avenue"
121 + assert added.route == "38" and added.dest == nil
122 + end
123 +
124 + test "no vehicles means no queries at all", %{sid: sid} do
125 + assert Storage.with_trip_context([], sid) == []
126 + end
127 + end
128 +
129 + describe "what the marker carries" do
130 + test "the popup gets route, destination, direction and mode" do
131 + html =
132 + render_component(&query_geospatial_map/1,
133 + queries: [],
134 + vehicle_positions: [
135 + vehicle(%{route: "38", dest: "Geary + 33rd Avenue",
136 + direction: "Outbound", mode: "Bus"})
137 + ]
138 + )
139 +
140 + assert html =~ ~s(route="38")
141 + assert html =~ ~s(dest="Geary + 33rd Avenue")
142 + assert html =~ ~s(direction="Outbound")
143 + assert html =~ ~s(mode="Bus")
144 + assert html =~ ~s(vehicle-id="V1")
145 + end
146 +
147 + test "the label says the route and where it is heading" do
148 + html =
149 + render_component(&query_geospatial_map/1,
150 + queries: [],
151 + vehicle_positions: [vehicle(%{route: "38", dest: "Geary + 33rd Avenue"})]
152 + )
153 +
154 + assert html =~ ~s(name="38 to Geary + 33rd Avenue")
155 + # never the placeholder that shipped before
156 + refute html =~ ~s(name="Marker")
157 + end
158 +
159 + test "an unscheduled vehicle falls back to its own id rather than nothing" do
160 + html =
161 + render_component(&query_geospatial_map/1,
162 + queries: [],
163 + vehicle_positions: [vehicle(%{trip_id: nil})]
164 + )
165 +
166 + assert html =~ ~s(name="V1")
167 + end
168 +
169 + test "missing context is left out, not printed as null" do
170 + html =
171 + render_component(&query_geospatial_map/1,
172 + queries: [],
173 + vehicle_positions: [vehicle(%{route: "38"})]
174 + )
175 +
176 + refute html =~ "null"
177 + assert html =~ ~s(route="38")
178 + end
179 + end
180 +end
added apps/room_sanctum/test/room_sanctum_web/live/query_route_lines_test.exs
+112 −0
@@ -0,0 +1,112 @@
1 +defmodule RoomSanctumWeb.QueryRouteLinesTest do
2 + @moduledoc """
3 + A stop query's map draws the routes calling at that stop, not every shape in
4 + the feed -- MBTA alone is hundreds of routes.
5 + """
6 + use RoomSanctumWeb.ConnCase
7 +
8 + import Phoenix.LiveViewTest
9 +
10 + alias RoomSanctum.{Accounts, Configuration, Repo}
11 + alias RoomSanctum.Storage.GTFS.{Route, Shape, Stop, StopTime, Trip}
12 +
13 + setup %{conn: conn} do
14 + {:ok, user} =
15 + Accounts.register_user(%{
16 + email: "qrl#{System.unique_integer([:positive])}@example.com",
17 + password: "hello world!hello world!"
18 + })
19 +
20 + {:ok, source} =
21 + Configuration.create_source(%{
22 + name: "MBTA", notes: "", type: :gtfs, enabled: true, user_id: user.id,
23 + config: %{"__type__" => "gtfs", "url" => "https://example.test/g.zip", "tz" => "UTC"}
24 + })
25 +
26 + sid = source.id
27 +
28 + for stop_id <- ["here", "elsewhere"] do
29 + Repo.insert!(%Stop{source_id: sid, stop_id: stop_id, stop_name: stop_id,
30 + stop_lat: 42.38, stop_lon: -71.11})
31 + end
32 +
33 + # A and B call at "here"; C and D are elsewhere in the system
34 + for {rid, stops} <- [{"A", ["here"]}, {"B", ["here", "elsewhere"]},
35 + {"C", ["elsewhere"]}, {"D", ["elsewhere"]}] do
36 + Repo.insert!(%Route{source_id: sid, route_id: rid, route_type: "3", route_short_name: rid})
37 + Repo.insert!(%Trip{source_id: sid, route_id: rid, trip_id: "T#{rid}",
38 + shape_id: "SH#{rid}", service_id: "s"})
39 +
40 + for i <- 1..4 do
41 + Repo.insert!(%Shape{source_id: sid, shape_id: "SH#{rid}", shape_pt_sequence: i,
42 + shape_pt_lat: 42.38 + i / 1000, shape_pt_lon: -71.11})
43 + end
44 +
45 + for {stop_id, seq} <- Enum.with_index(stops) do
46 + Repo.insert!(%StopTime{source_id: sid, trip_id: "T#{rid}", stop_id: stop_id,
47 + stop_sequence: seq})
48 + end
49 + end
50 +
51 + {:ok, query} =
52 + Configuration.create_query(%{
53 + name: "Here", notes: "", source_id: sid, user_id: user.id,
54 + query: %{"__type__" => "gtfs", "stop" => "here"}
55 + })
56 +
57 + %{conn: log_in_user(conn, user), query: query, sid: sid}
58 + end
59 +
60 + defp drawn_lines(html) do
61 + Regex.scan(~r/<leaflet-line[^>]*id="geospatial-map-line-([^"]+)"/, html)
62 + |> Enum.map(&List.last/1)
63 + |> Enum.sort()
64 + end
65 +
66 + test "only the routes calling at the stop are drawn", %{conn: conn, query: query, sid: sid} do
67 + {:ok, live, _html} = live(conn, Routes.query_show_path(conn, :show, query))
68 +
69 + html = render_click(live, "toggle-route-lines", %{})
70 +
71 + assert drawn_lines(html) == ["#{sid}-A", "#{sid}-B"]
72 + end
73 +
74 + test "the rest of the system is left off", %{conn: conn, query: query, sid: sid} do
75 + {:ok, live, _html} = live(conn, Routes.query_show_path(conn, :show, query))
76 + html = render_click(live, "toggle-route-lines", %{})
77 +
78 + refute html =~ "#{sid}-C"
79 + refute html =~ "#{sid}-D"
80 + end
81 +
82 + test "nothing is drawn until asked for", %{conn: conn, query: query} do
83 + {:ok, _live, html} = live(conn, Routes.query_show_path(conn, :show, query))
84 +
85 + assert drawn_lines(html) == []
86 + end
87 +
88 + test "toggling off clears them again", %{conn: conn, query: query} do
89 + {:ok, live, _html} = live(conn, Routes.query_show_path(conn, :show, query))
90 +
91 + render_click(live, "toggle-route-lines", %{})
92 + off = render_click(live, "toggle-route-lines", %{})
93 +
94 + assert drawn_lines(off) == []
95 + end
96 +
97 + test "a stop nothing calls at draws nothing, rather than everything", %{conn: conn, sid: sid, query: q} do
98 + Repo.insert!(%Stop{source_id: sid, stop_id: "orphan", stop_name: "orphan",
99 + stop_lat: 42.4, stop_lon: -71.0})
100 +
101 + {:ok, orphan} =
102 + Configuration.create_query(%{
103 + name: "Orphan", notes: "", source_id: sid, user_id: q.user_id,
104 + query: %{"__type__" => "gtfs", "stop" => "orphan"}
105 + })
106 +
107 + {:ok, live, _html} = live(conn, Routes.query_show_path(conn, :show, orphan))
108 + html = render_click(live, "toggle-route-lines", %{})
109 +
110 + assert drawn_lines(html) == []
111 + end
112 +end

Parents: 781a566