Add a Plus reading of GTFS data, and the realtime it was hiding

9c789e8 · Gabriel Morell · 2026-09-01 17:17

61 files +3125 -75
Message
{commit_body(@commit)}

Files changed

modified apps/room_gtfs/lib/room_gtfs/feed_tester.ex
+207 −1
@@ -80,7 +80,10 @@ defmodule RoomGtfs.FeedTester do
80 80 result
81 81 |> Map.put(:entities, counts)
82 82 |> Map.put(:total, length(feed.entity))
83 + |> Map.put(:capabilities, capabilities(feed))
83 84 |> check_empty(feed, counts, kinds)
85 + |> check_incrementality(feed)
86 + |> check_stop_identification(feed)
84 87 |> check_age(feed)
85 88 |> check_extensions(feed)
86 89 |> check_agency(feed, source)
@@ -88,8 +91,13 @@ defmodule RoomGtfs.FeedTester do
88 91 |> finish()
89 92 end
90 93
91 − defp check_empty(result, _feed, counts, kinds) do
94 + defp check_empty(result, feed, counts, kinds) do
92 95 cond do
96 + # A differential feed saying nothing has changed since the last poll is a
97 + # feed working correctly, not an empty one.
98 + result.total == 0 and differential?(feed) ->
99 + add(result, :info, "no changes in this delta")
100 +
93 101 result.total == 0 ->
94 102 add(result, :warn, "decoded, but carries no entities at all right now")
95 103
@@ -112,6 +120,83 @@ defmodule RoomGtfs.FeedTester do
112 120 end
113 121 end
114 122
123 + # A differential feed is a stream of edits to a feed held open, not a
124 + # snapshot -- so the message in front of you is not the feed, and its entity
125 + # count says nothing about how much this source is carrying. Worth saying
126 + # out loud, because every other number on this page is read as a total.
127 + @doc false
128 + def check_incrementality(result, feed) do
129 + deleted = Enum.count(feed.entity, & &1.is_deleted)
130 +
131 + case feed.header && feed.header.incrementality do
132 + :DIFFERENTIAL ->
133 + add(
134 + result,
135 + :info,
136 + "differential: each message carries only what changed, and entities are held " <>
137 + "between polls rather than replaced. What is listed here is this delta" <>
138 + deleted_note(deleted) <> ", not the whole feed"
139 + )
140 +
141 + _otherwise when deleted > 0 ->
142 + add(
143 + result,
144 + :warn,
145 + "#{deleted} entities are marked deleted in a feed that calls itself a full " <>
146 + "dataset -- a full dataset is already the whole truth, so these say nothing " <>
147 + "and are being kept"
148 + )
149 +
150 + _otherwise ->
151 + result
152 + end
153 + end
154 +
155 + defp deleted_note(0), do: ""
156 + defp deleted_note(1), do: ", one of them a deletion"
157 + defp deleted_note(n), do: ", #{n} of them deletions"
158 +
159 + # GTFS-RT lets a stop update name its stop by id, by position in the trip, or
160 + # both. Live arrivals are matched by stop id alone, so a feed that only counts
161 + # positions matches nothing at all: no live times, no error, no clue. The
162 + # static schedule is what would have to be consulted to turn a sequence back
163 + # into a stop, and nothing does that.
164 + @doc false
165 + def check_stop_identification(result, feed) do
166 + stus =
167 + feed.entity
168 + |> Enum.filter(& &1.trip_update)
169 + |> Enum.flat_map(& &1.trip_update.stop_time_update)
170 +
171 + total = length(stus)
172 + missing = Enum.count(stus, &(&1.stop_id in [nil, ""]))
173 +
174 + cond do
175 + total == 0 or missing == 0 ->
176 + result
177 +
178 + missing == total ->
179 + add(
180 + result,
181 + :error,
182 + "identifies stops only by their position in the trip, never by stop id -- " <>
183 + "arrivals are matched on stop id, so nothing in this feed will ever match"
184 + )
185 +
186 + true ->
187 + add(
188 + result,
189 + :warn,
190 + "#{missing} of #{total} stop updates carry no stop id, only a position in the " <>
191 + "trip -- those cannot be matched to a stop and are skipped"
192 + )
193 + end
194 + end
195 +
196 + defp differential?(feed) do
197 + feed.header && feed.header.incrementality == :DIFFERENTIAL
198 + end
199 +
115 200 defp check_age(result, feed) do
116 201 case feed.header && feed.header.timestamp do
117 202 nil ->
@@ -394,6 +479,127 @@ defmodule RoomGtfs.FeedTester do
394 479 Regex.replace(~r/([?&](?:api_key|apikey|key|token)=)([^&]{4})[^&]*/i, url, "\\1\\2****")
395 480 end
396 481
482 + @doc """
483 + Which optional GTFS-RT fields this feed actually fills in.
484 +
485 + Everything past a trip id and a time is optional in GTFS-RT, and feeds vary
486 + enormously in how much of it they send -- MBTA populates per-carriage
487 + occupancy and skipped stops but never a delay or a track assignment; another
488 + agency does the reverse. Nothing announces this. You find out by building a
489 + view against a field and seeing an empty column, which is a slow way to learn
490 + that the feed was never going to say.
491 +
492 + Reported as `%{field => %{present: n, of: total}}` per feed kind, counted over
493 + what actually arrived rather than declared anywhere. A field nothing reports
494 + is still listed, with a zero: "this feed does not do that" is the answer
495 + worth having.
496 + """
497 + def capabilities(feed) do
498 + tus = feed.entity |> Enum.filter(& &1.trip_update) |> Enum.map(& &1.trip_update)
499 + vps = feed.entity |> Enum.filter(& &1.vehicle) |> Enum.map(& &1.vehicle)
500 + alerts = feed.entity |> Enum.filter(& &1.alert) |> Enum.map(& &1.alert)
501 + stus = Enum.flat_map(tus, & &1.stop_time_update)
502 + events = stus |> Enum.map(&(&1.arrival || &1.departure)) |> Enum.reject(&is_nil/1)
503 + carriages = Enum.flat_map(vps, &(&1.multi_carriage_details || []))
504 + informed = Enum.flat_map(alerts, &(&1.informed_entity || []))
505 +
506 + %{}
507 + |> put_caps(stus, [
508 + {"live occupancy (per stop)", &set?(&1.departure_occupancy_status)},
509 + {"skipped stops", &(&1.schedule_relationship == :SKIPPED)},
510 + {"track assignment", &(&1.stop_time_properties && set?(&1.stop_time_properties.assigned_stop_id))},
511 + {"per-stop headsign", &(&1.stop_time_properties && set?(&1.stop_time_properties.stop_headsign))}
512 + ])
513 + |> put_caps(events, [
514 + {"delay", &(&1.delay != nil)},
515 + {"uncertainty", &(&1.uncertainty != nil)}
516 + ])
517 + |> put_caps(tus, [
518 + {"cancelled / added trips", &(&1.trip.schedule_relationship not in [nil, :SCHEDULED])}
519 + ])
520 + |> put_caps(vps, [
521 + {"occupancy", &set?(&1.occupancy_status)},
522 + {"occupancy %", &(&1.occupancy_percentage != nil and &1.occupancy_percentage >= 0)},
523 + {"per-carriage occupancy", &(&1.multi_carriage_details != [])},
524 + {"congestion level", &set?(&1.congestion_level)},
525 + {"position", &(&1.position != nil)},
526 + {"bearing", &(&1.position && &1.position.bearing != nil)},
527 + {"vehicle label", &(&1.vehicle && set?(&1.vehicle.label))}
528 + ])
529 + |> put_caps(carriages, [
530 + {"carriage occupancy", &set?(&1.occupancy_status)}
531 + ])
532 + |> put_caps(alerts, [
533 + {"alert headline", &said?(&1.header_text)},
534 + {"alert detail", &said?(&1.description_text)},
535 + {"alert effect", &set?(&1.effect)},
536 + {"alert cause", &set?(&1.cause)},
537 + {"alert severity", &set?(&1.severity_level)},
538 + {"alert period", &(&1.active_period != [])},
539 + {"alert link", &said?(&1.url)},
540 + {"alert image", &(&1.image != nil)},
541 + {"alert audio text", &said?(&1.tts_header_text)}
542 + ])
543 + # An alert only reaches a query if it names something the query knows
544 + # about, and `query_alerts/3` matches on stop, route, or the whole agency.
545 + # A feed that targets only by route will never raise an alert on a query
546 + # that is watching one stop of it -- which is a property of the feed, not a
547 + # bug, and is invisible until you go looking for the alert that never came.
548 + |> put_caps(informed, [
549 + {"targets stops", &said_id?(&1.stop_id)},
550 + {"targets routes", &said_id?(&1.route_id)},
551 + {"targets trips", &(&1.trip != nil)},
552 + {"agency-wide", &agency_wide?/1}
553 + ])
554 + end
555 +
556 + # An entity naming nothing but an agency -- or naming nothing at all -- is an
557 + # alert about everything the source carries.
558 + defp agency_wide?(entity) do
559 + not said_id?(entity.stop_id) and not said_id?(entity.route_id) and entity.trip == nil and
560 + entity.route_type == nil
561 + end
562 +
563 + defp said_id?(id), do: id not in [nil, ""]
564 +
565 + # A translated string is a list of translations, so "present" means it
566 + # actually carries one with something in it -- an empty envelope is a field
567 + # the publisher wired up and never filled.
568 + defp said?(nil), do: false
569 +
570 + defp said?(%{translation: translations}) do
571 + Enum.any?(translations, &(String.trim(&1.text || "") != ""))
572 + end
573 +
574 + defp said?(_other), do: false
575 +
576 + # A field only counts as reported when it says something. NO_DATA_AVAILABLE
577 + # is the feed declining to answer, which is what an absent field means too.
578 + defp set?(nil), do: false
579 + defp set?(""), do: false
580 + defp set?(:NO_DATA_AVAILABLE), do: false
581 + # The proto defaults for an alert's cause, effect and severity. A field left
582 + # alone decodes to these, so they mean the publisher said nothing, not that
583 + # the answer is "unknown".
584 + defp set?(:UNKNOWN_CAUSE), do: false
585 + defp set?(:UNKNOWN_EFFECT), do: false
586 + defp set?(:UNKNOWN_SEVERITY), do: false
587 + defp set?(_), do: true
588 +
589 + # Counted only where there was something to count: a feed with no vehicles in
590 + # it has nothing to say about bearings, and listing them all as 0 of 0 would
591 + # read as a feed that refuses to give them.
592 + defp put_caps(acc, [], _checks), do: acc
593 +
594 + defp put_caps(acc, items, checks) do
595 + total = length(items)
596 +
597 + Enum.reduce(checks, acc, fn {label, check}, inner ->
598 + present = Enum.count(items, fn item -> check.(item) == true end)
599 + Map.put(inner, label, %{present: present, of: total})
600 + end)
601 + end
602 +
397 603 defp add(result, severity, message),
398 604 do: Map.update!(result, :findings, &(&1 ++ [{severity, message}]))
399 605
modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+296 −1
@@ -156,6 +156,11 @@ defmodule RoomGtfs.Worker do
156 156 res
157 157
158 158 rtvals ->
159 + # How full the vehicle is may be reported per stop in the trip
160 + # update, or only by the vehicle's own position report -- so the
161 + # positions are fetched once here to fall back on.
162 + vehicles = occupancy_vehicles(id, inst.config, trips)
163 +
159 164 res
160 165 |> Enum.map(fn x ->
161 166 case Enum.find(rtvals, fn v ->
@@ -167,6 +172,13 @@ defmodule RoomGtfs.Worker do
167 172 val ->
168 173 stu = val.trip_update.stop_time_update
169 174 time_event = stu && (stu.arrival || stu.departure)
175 +
176 + x =
177 + x
178 + |> merge_occupancy(occupancy_for(inst.config, x.trip_id, stu, vehicles))
179 + |> merge_schedule_relationship(val.trip_update, stu)
180 + |> merge_stop_properties(stu)
181 +
170 182 case time_event do
171 183 nil ->
172 184 x
@@ -179,10 +191,194 @@ defmodule RoomGtfs.Worker do
179 191 end
180 192 end
181 193 end)
194 + |> resolve_assigned_platforms(id)
195 + end
196 + end
197 + end
198 +
199 + # The vehicle positions for the trips calling at a stop, or [] for a source
200 + # that publishes none. Occupancy is the only thing wanted from them here, and
201 + # a source with no vehicle feed still gets whatever the trip updates carry.
202 + defp occupancy_vehicles(id, config, trips) do
203 + case RoomGtfs.Worker.RT.rt_url_for(config, :vp) do
204 + nil ->
205 + []
206 +
207 + _val ->
208 + try do
209 + get_current_vehicle_positions(id, trips)
210 + catch
211 + :exit, _ -> []
182 212 end
183 213 end
184 214 end
185 215
216 + # An arrival says nothing about how full it is unless the feed did.
217 + defp merge_occupancy(arrival, nil), do: arrival
218 +
219 + defp merge_occupancy(arrival, %{status: status, pct: pct, carriages: carriages}) do
220 + arrival
221 + |> maybe_put(:occupancy, status)
222 + |> maybe_put(:occupancy_pct, pct)
223 + |> maybe_put(:carriages, presence(carriages))
224 + end
225 +
226 + # A key nothing was reported for is left off rather than set to nil, so that
227 + # "the feed did not say" and "the feed said nothing is there" stay
228 + # distinguishable further down.
229 + defp maybe_put(map, _key, nil), do: map
230 + defp maybe_put(map, key, value), do: Map.put(map, key, value)
231 +
232 + defp presence([]), do: nil
233 + defp presence(""), do: nil
234 + defp presence(value), do: value
235 +
236 + @doc false
237 + # How full a vehicle is, as a GTFS-RT OccupancyStatus atom and, where the
238 + # feed says so, a percentage. The trip update may report the status per stop;
239 + # otherwise the vehicle's own position report says it for the whole trip, and
240 + # only that carries a percentage. NO_DATA_AVAILABLE is the feed declining to
241 + # answer, so it reads the same as an absent field -- NOT_ACCEPTING_PASSENGERS
242 + # is a real answer and is kept.
243 + def occupancy_for(config, scheduled_trip_id, stu, vehicles) do
244 + vehicle =
245 + Enum.find(vehicles, fn v ->
246 + v.trip_id && trip_id_match?(config, scheduled_trip_id, v.trip_id)
247 + end)
248 +
249 + status =
250 + occupancy_status(stu && stu.departure_occupancy_status) ||
251 + occupancy_status(vehicle && Map.get(vehicle, :occupancy_status))
252 +
253 + carriages = carriage_occupancy(vehicle)
254 +
255 + case {status, carriages} do
256 + {nil, []} ->
257 + nil
258 +
259 + {status, carriages} ->
260 + %{
261 + status: status,
262 + pct: occupancy_pct(vehicle && Map.get(vehicle, :occupancy_percentage)),
263 + carriages: carriages
264 + }
265 + end
266 + end
267 +
268 + @doc false
269 + # A train reporting each carriage separately is the difference between "the
270 + # train is full" and "walk to the back". Kept in the sequence the feed gives,
271 + # because that is the order they are coupled in and the whole point is which
272 + # end of the platform to stand at. A carriage the feed said nothing about
273 + # stays in the list -- a gap in the middle of a train is itself worth
274 + # drawing -- it simply has no occupancy.
275 + def carriage_occupancy(nil), do: []
276 +
277 + def carriage_occupancy(vehicle) do
278 + vehicle
279 + |> Map.get(:carriages)
280 + |> List.wrap()
281 + |> Enum.sort_by(&(&1.sequence || 0))
282 + |> Enum.map(fn c ->
283 + %{
284 + id: c.id,
285 + label: presence(c.label),
286 + sequence: c.sequence,
287 + occupancy: occupancy_status(c.occupancy_status),
288 + occupancy_pct: occupancy_pct(c.occupancy_percentage)
289 + }
290 + end)
291 + end
292 +
293 + # -1 is the protobuf default standing in for "not reported".
294 + defp occupancy_pct(pct) when is_integer(pct) and pct >= 0, do: pct
295 + defp occupancy_pct(_), do: nil
296 +
297 + @doc false
298 + # Two different ways an arrival is not going to happen, and they are not the
299 + # same thing: the trip may be cancelled outright, or it may be running fine
300 + # and simply not calling here. Both used to draw as an ordinary arrival,
301 + # which is the one wrong thing a departure board can do.
302 + #
303 + # SCHEDULED is the proto default and means "as published", so it is left off
304 + # rather than recorded -- only a departure from the schedule is news.
305 + def merge_schedule_relationship(arrival, trip_update, stu) do
306 + trip = trip_update && trip_update.trip
307 +
308 + arrival
309 + |> maybe_put(:trip_status, relationship(trip && trip.schedule_relationship))
310 + |> maybe_put(:stop_status, relationship(stu && stu.schedule_relationship))
311 + end
312 +
313 + defp relationship(nil), do: nil
314 + defp relationship(:SCHEDULED), do: nil
315 + defp relationship(rel) when is_atom(rel), do: rel
316 + # A feed may send a number outside the enum this was generated against.
317 + defp relationship(_), do: nil
318 +
319 + @doc false
320 + # What the feed says about this call specifically, rather than about the trip:
321 + # the track it has been given, and a headsign that overrides the trip's own.
322 + # A short-turn announces itself here -- the trip still says "Downtown" while
323 + # this particular call says it terminates early.
324 + def merge_stop_properties(arrival, nil), do: arrival
325 +
326 + def merge_stop_properties(arrival, stu) do
327 + props = Map.get(stu, :stop_time_properties)
328 +
329 + arrival
330 + |> maybe_put(:assigned_stop_id, props && presence(props.assigned_stop_id))
331 + |> maybe_put(:stop_headsign, props && presence(props.stop_headsign))
332 + end
333 +
334 + # A track is announced as a stop id, which is an internal string nobody wants
335 + # to read -- "127N" rather than "Track 3". The static feed knows what that
336 + # stop is called, so the ids are resolved together in one query rather than
337 + # one per arrival, and an id the static feed has never heard of is shown as
338 + # itself rather than dropped.
339 + defp resolve_assigned_platforms(arrivals, source_id) do
340 + ids =
341 + arrivals
342 + |> Enum.map(&Map.get(&1, :assigned_stop_id))
343 + |> Enum.reject(&is_nil/1)
344 + |> Enum.uniq()
345 +
346 + case ids do
347 + [] ->
348 + arrivals
349 +
350 + ids ->
351 + stops = Storage.get_stops_by_ids(source_id, ids)
352 +
353 + Enum.map(arrivals, fn a ->
354 + case Map.get(a, :assigned_stop_id) do
355 + nil -> a
356 + id -> Map.put(a, :assigned_platform, platform_label(Map.get(stops, id)) || id)
357 + end
358 + end)
359 + end
360 + end
361 +
362 + defp platform_label(nil), do: nil
363 +
364 + defp platform_label(stop) do
365 + presence(stop.platform_code) || presence(stop.platform_name) || presence(stop.stop_name)
366 + end
367 +
368 + defp occupancy_status(nil), do: nil
369 + defp occupancy_status(:NO_DATA_AVAILABLE), do: nil
370 + defp occupancy_status(status) when is_atom(status), do: status
371 +
372 + # A feed may send a number outside the enum the generated module knows; that
373 + # is not an occupancy anyone can draw, so it reads as no answer.
374 + defp occupancy_status(status) when is_integer(status) do
375 + try do
376 + TransitRealtime.VehiclePosition.OccupancyStatus.key(status) |> occupancy_status()
377 + rescue
378 + _ -> nil
379 + end
380 + end
381 +
186 382 # etc
187 383 def init(opts) do
188 384 Periodic.start_link(
@@ -473,12 +669,19 @@ defmodule RoomGtfs.Worker.RT do
473 669 end)
474 670 |> Enum.map(fn alert ->
475 671 route_id = Enum.find_value(alert.informed_entity, fn e -> e.route_id end)
672 +
476 673 %{
477 674 effect: alert.effect |> to_string(),
478 675 cause: alert.cause |> to_string(),
479 676 header: get_translation(alert.header_text),
480 677 description: get_translation(alert.description_text),
481 678 route_id: route_id,
679 + severity: alert.severity_level |> to_string(),
680 + # Whether the alert names this stop, as opposed to reaching it by
681 + # naming the line it is on. "Elevator out here" and "delays on the
682 + # Red Line" are both true of the stop and are not the same news.
683 + stop_specific:
684 + Enum.any?(alert.informed_entity, fn e -> e.stop_id != nil and e.stop_id == stop end)
482 685 }
483 686 end)
484 687 end
@@ -605,6 +808,24 @@ defmodule RoomGtfs.Worker.RT do
605 808
606 809 def vehicle_positions_from(_feed, _stops), do: []
607 810
811 + # The occupancy of each carriage, as the feed sent it -- the enum values are
812 + # left raw here, the same as the vehicle's own occupancy_status, and are
813 + # normalised once where they are read.
814 + defp carriage_details(v) do
815 + v
816 + |> Map.get(:multi_carriage_details)
817 + |> List.wrap()
818 + |> Enum.map(fn c ->
819 + %{
820 + id: c.id,
821 + label: c.label,
822 + sequence: c.carriage_sequence,
823 + occupancy_status: c.occupancy_status,
824 + occupancy_percentage: c.occupancy_percentage
825 + }
826 + end)
827 + end
828 +
608 829 defp place_vehicle(v, stops) do
609 830 base = %{
610 831 vehicle_id: v.vehicle && v.vehicle.id,
@@ -612,6 +833,9 @@ defmodule RoomGtfs.Worker.RT do
612 833 route_id: v.trip && v.trip.route_id,
613 834 stop_id: v.stop_id,
614 835 current_status: v.current_status,
836 + occupancy_status: v.occupancy_status,
837 + occupancy_percentage: v.occupancy_percentage,
838 + carriages: carriage_details(v),
615 839 timestamp: v.timestamp
616 840 }
617 841
@@ -901,8 +1125,13 @@ defmodule RoomGtfs.Worker.RT do
901 1125 # that means "the same URL is in all three fields by mistake", and
902 1126 # would say it about a correct configuration.
903 1127 sliced = slice(feed, entity_field(kind))
1128 +
1129 + # Health records the delta, not the running total: what this poll
1130 + # actually received is the honest reading of whether the feed is
1131 + # alive, and a differential feed reporting "nothing changed" has
1132 + # genuinely received nothing.
904 1133 record_health(acc, kind, url, {:ok, sliced}, took)
905 − store_feed(acc, kind, sliced)
1134 + store_feed(acc, kind, merge_feed(current_feed(acc, kind), sliced))
906 1135 end)
907 1136
908 1137 {:error, error} ->
@@ -980,6 +1209,72 @@ defmodule RoomGtfs.Worker.RT do
980 1209 %{feed | entity: Enum.filter(feed.entity, &Map.get(&1, field))}
981 1210 end
982 1211
1212 + defp current_feed(state, :sa), do: state.rt_sa
1213 + defp current_feed(state, :tu), do: state.rt_tu
1214 + defp current_feed(state, :vp), do: state.rt_vp
1215 +
1216 + @doc """
1217 + What to hold after a poll, given what was held before it.
1218 +
1219 + GTFS-RT publishes in one of two modes, and they mean opposite things by the
1220 + same message. A **full dataset** is a snapshot: what arrived is the whole
1221 + truth and replaces whatever was held. A **differential** feed is a stream of
1222 + edits against a feed held open -- each message carries only what changed,
1223 + entities are keyed by `FeedEntity.id`, and `is_deleted` means drop this one.
1224 +
1225 + Replacing on every poll, which is what used to happen, is right for the first
1226 + and silently wrong for the second: everything not restated in the newest
1227 + delta disappears, so a differential source would show a handful of vehicles
1228 + and no explanation.
1229 +
1230 + Order is kept stable -- entities already held stay where they were and new
1231 + ones join the end -- so that a feed does not appear to reshuffle itself on
1232 + every poll.
1233 +
1234 + A differential message with nothing held yet is applied to an empty feed,
1235 + which is the only sensible reading: there is no earlier state to edit, so
1236 + what arrives is what there is, minus anything it deletes.
1237 + """
1238 + def merge_feed(previous, incoming) do
1239 + if differential?(incoming) do
1240 + %{incoming | entity: merge_entities(entities(previous), incoming.entity)}
1241 + else
1242 + incoming
1243 + end
1244 + end
1245 +
1246 + defp differential?(feed) do
1247 + feed.header && feed.header.incrementality == :DIFFERENTIAL
1248 + end
1249 +
1250 + defp entities(nil), do: []
1251 + defp entities(feed), do: feed.entity
1252 +
1253 + defp merge_entities(held, delta) do
1254 + initial = {held |> Enum.map(& &1.id) |> Enum.reverse(), Map.new(held, &{&1.id, &1})}
1255 +
1256 + # Folded in order, so a message that deletes an entity and then sends it
1257 + # again -- or sends it twice -- ends up with what it said last.
1258 + {order, current} =
1259 + Enum.reduce(delta, initial, fn entity, {order, current} ->
1260 + cond do
1261 + entity.is_deleted -> {order, Map.delete(current, entity.id)}
1262 + Map.has_key?(current, entity.id) -> {order, Map.put(current, entity.id, entity)}
1263 + true -> {[entity.id | order], Map.put(current, entity.id, entity)}
1264 + end
1265 + end)
1266 +
1267 + order
1268 + |> Enum.reverse()
1269 + |> Enum.uniq()
1270 + |> Enum.flat_map(fn id ->
1271 + case Map.fetch(current, id) do
1272 + {:ok, entity} -> [entity]
1273 + :error -> []
1274 + end
1275 + end)
1276 + end
1277 +
983 1278
984 1279 def handle_call({:query_realtime, trips, stop}, _from, state) do
985 1280 # IO.inspect(trips)
added apps/room_gtfs/test/differential_feed_test.exs
+145 −0
@@ -0,0 +1,145 @@
1 +defmodule RoomGtfs.DifferentialFeedTest do
2 + @moduledoc """
3 + Holding a differential feed open.
4 +
5 + GTFS-RT publishes in two modes that mean opposite things by the same message.
6 + A full dataset is a snapshot and replaces what was held. A differential feed
7 + is a stream of edits: each message carries only what changed, keyed by entity
8 + id, and `is_deleted` means drop it. Replacing on every poll -- correct for the
9 + first -- silently discards everything the newest delta did not restate.
10 + """
11 + use ExUnit.Case, async: true
12 +
13 + alias RoomGtfs.Worker.RT
14 + alias TransitRealtime, as: T
15 +
16 + defp feed(entities, incrementality) do
17 + %T.FeedMessage{
18 + header: %T.FeedHeader{gtfs_realtime_version: "2.0", incrementality: incrementality},
19 + entity: entities
20 + }
21 + end
22 +
23 + defp full(entities), do: feed(entities, :FULL_DATASET)
24 + defp delta(entities), do: feed(entities, :DIFFERENTIAL)
25 +
26 + # The label rides along on the trip id so an updated entity is telling apart
27 + # from the one it replaced.
28 + defp vehicle(id, label \\ "a") do
29 + %T.FeedEntity{
30 + id: id,
31 + vehicle: %T.VehiclePosition{trip: %T.TripDescriptor{trip_id: label}}
32 + }
33 + end
34 +
35 + defp deletion(id), do: %T.FeedEntity{id: id, is_deleted: true}
36 +
37 + defp ids(feed), do: Enum.map(feed.entity, & &1.id)
38 + defp label(feed, id), do: Enum.find(feed.entity, &(&1.id == id)).vehicle.trip.trip_id
39 +
40 + describe "a full dataset" do
41 + test "replaces whatever was held" do
42 + held = full([vehicle("a"), vehicle("b")])
43 +
44 + assert RT.merge_feed(held, full([vehicle("c")])) |> ids() == ["c"]
45 + end
46 +
47 + test "replaces a differential history too, because it is the whole truth" do
48 + held = delta([vehicle("a"), vehicle("b")])
49 +
50 + assert RT.merge_feed(held, full([vehicle("c")])) |> ids() == ["c"]
51 + end
52 + end
53 +
54 + describe "a differential message" do
55 + test "adds to what is held rather than replacing it" do
56 + held = delta([vehicle("a"), vehicle("b")])
57 +
58 + assert RT.merge_feed(held, delta([vehicle("c")])) |> ids() == ["a", "b", "c"]
59 + end
60 +
61 + test "updates an entity in place, keeping its position" do
62 + held = delta([vehicle("a", "old"), vehicle("b", "old")])
63 +
64 + merged = RT.merge_feed(held, delta([vehicle("a", "new")]))
65 +
66 + assert ids(merged) == ["a", "b"]
67 + assert label(merged, "a") == "new"
68 + assert label(merged, "b") == "old"
69 + end
70 +
71 + test "a deletion drops the entity" do
72 + held = delta([vehicle("a"), vehicle("b"), vehicle("c")])
73 +
74 + assert RT.merge_feed(held, delta([deletion("b")])) |> ids() == ["a", "c"]
75 + end
76 +
77 + test "deletions are not themselves kept as entities" do
78 + merged = RT.merge_feed(delta([vehicle("a")]), delta([deletion("z")]))
79 +
80 + assert ids(merged) == ["a"]
81 + end
82 +
83 + test "nothing changed is not the feed emptying itself" do
84 + held = delta([vehicle("a"), vehicle("b")])
85 +
86 + assert RT.merge_feed(held, delta([])) |> ids() == ["a", "b"]
87 + end
88 +
89 + test "the newest header wins, so freshness is the delta's" do
90 + held = delta([vehicle("a")])
91 + incoming = %{delta([]) | header: %T.FeedHeader{gtfs_realtime_version: "2.0", timestamp: 99}}
92 +
93 + assert RT.merge_feed(held, incoming).header.timestamp == 99
94 + end
95 + end
96 +
97 + describe "within one message" do
98 + test "the last word wins for a repeated entity" do
99 + merged = RT.merge_feed(delta([]), delta([vehicle("a", "first"), vehicle("a", "second")]))
100 +
101 + assert label(merged, "a") == "second"
102 + end
103 +
104 + test "deleted and then sent again means sent" do
105 + held = delta([vehicle("a", "old")])
106 +
107 + merged = RT.merge_feed(held, delta([deletion("a"), vehicle("a", "back")]))
108 +
109 + assert ids(merged) == ["a"]
110 + assert label(merged, "a") == "back"
111 + end
112 +
113 + test "sent and then deleted means deleted" do
114 + merged = RT.merge_feed(delta([]), delta([vehicle("a"), deletion("a")]))
115 +
116 + assert ids(merged) == []
117 + end
118 + end
119 +
120 + describe "nothing held yet" do
121 + test "the first delta is applied to an empty feed" do
122 + assert RT.merge_feed(nil, delta([vehicle("a")])) |> ids() == ["a"]
123 + end
124 +
125 + test "a first delta deleting something it never sent drops it" do
126 + assert RT.merge_feed(nil, delta([vehicle("a"), deletion("b")])) |> ids() == ["a"]
127 + end
128 +
129 + test "a first full dataset is simply the feed" do
130 + assert RT.merge_feed(nil, full([vehicle("a")])) |> ids() == ["a"]
131 + end
132 + end
133 +
134 + test "several polls accumulate the way the feed intends" do
135 + merged =
136 + nil
137 + |> RT.merge_feed(delta([vehicle("a"), vehicle("b")]))
138 + |> RT.merge_feed(delta([vehicle("c")]))
139 + |> RT.merge_feed(delta([deletion("a"), vehicle("b", "moved")]))
140 + |> RT.merge_feed(delta([]))
141 +
142 + assert ids(merged) == ["b", "c"]
143 + assert label(merged, "b") == "moved"
144 + end
145 +end
added apps/room_gtfs/test/feed_capabilities_test.exs
+282 −0
@@ -0,0 +1,282 @@
1 +defmodule RoomGtfs.FeedCapabilitiesTest do
2 + @moduledoc """
3 + What a realtime feed actually fills in.
4 +
5 + Everything past a trip id and a time is optional in GTFS-RT, and a feed says
6 + nothing about which parts it bothers with. Two agencies both "support
7 + GTFS-RT" and share almost no optional fields.
8 + """
9 + use ExUnit.Case, async: true
10 +
11 + alias RoomGtfs.FeedTester
12 + alias TransitRealtime, as: T
13 +
14 + defp feed(entities) do
15 + %T.FeedMessage{header: %T.FeedHeader{gtfs_realtime_version: "2.0"}, entity: entities}
16 + end
17 +
18 + defp vehicle(attrs) do
19 + %T.FeedEntity{
20 + id: "e",
21 + vehicle: struct(%T.VehiclePosition{trip: %T.TripDescriptor{trip_id: "t1"}}, attrs)
22 + }
23 + end
24 +
25 + defp trip_update(stus, trip_attrs \\ []) do
26 + %T.FeedEntity{
27 + id: "e",
28 + trip_update: %T.TripUpdate{
29 + trip: struct(%T.TripDescriptor{trip_id: "t1"}, trip_attrs),
30 + stop_time_update: stus
31 + }
32 + }
33 + end
34 +
35 + defp stu(attrs) do
36 + struct(%T.TripUpdate.StopTimeUpdate{stop_id: "s1", schedule_relationship: :SCHEDULED}, attrs)
37 + end
38 +
39 + describe "vehicle positions" do
40 + test "a field the feed fills in is counted" do
41 + caps =
42 + FeedTester.capabilities(
43 + feed([
44 + vehicle(occupancy_status: :FULL),
45 + vehicle(occupancy_status: :EMPTY),
46 + vehicle(occupancy_status: nil)
47 + ])
48 + )
49 +
50 + assert caps["occupancy"] == %{present: 2, of: 3}
51 + end
52 +
53 + test "a field nothing reports is still listed, at zero" do
54 + caps = FeedTester.capabilities(feed([vehicle(occupancy_status: :FULL)]))
55 +
56 + # The answer worth having: this feed does not do congestion.
57 + assert caps["congestion level"] == %{present: 0, of: 1}
58 + end
59 +
60 + test "NO_DATA_AVAILABLE is the feed declining, not reporting" do
61 + caps = FeedTester.capabilities(feed([vehicle(occupancy_status: :NO_DATA_AVAILABLE)]))
62 +
63 + assert caps["occupancy"] == %{present: 0, of: 1}
64 + end
65 +
66 + test "-1 is the occupancy percentage default, not a percentage" do
67 + caps =
68 + FeedTester.capabilities(
69 + feed([vehicle(occupancy_percentage: -1), vehicle(occupancy_percentage: 40)])
70 + )
71 +
72 + assert caps["occupancy %"] == %{present: 1, of: 2}
73 + end
74 +
75 + test "carriages are counted as carriages, not as vehicles" do
76 + car = fn status ->
77 + %T.VehiclePosition.CarriageDetails{carriage_sequence: 1, occupancy_status: status}
78 + end
79 +
80 + caps =
81 + FeedTester.capabilities(
82 + feed([
83 + vehicle(multi_carriage_details: [car.(:EMPTY), car.(:NO_DATA_AVAILABLE)]),
84 + vehicle(multi_carriage_details: [])
85 + ])
86 + )
87 +
88 + assert caps["per-carriage occupancy"] == %{present: 1, of: 2}
89 + assert caps["carriage occupancy"] == %{present: 1, of: 2}
90 + end
91 +
92 + test "a bearing is counted against the vehicles, not the positions" do
93 + pos = %T.Position{latitude: 1.0, longitude: 2.0}
94 +
95 + caps =
96 + FeedTester.capabilities(
97 + feed([
98 + vehicle(position: %{pos | bearing: 90.0}),
99 + vehicle(position: pos),
100 + vehicle(position: nil)
101 + ])
102 + )
103 +
104 + assert caps["position"] == %{present: 2, of: 3}
105 + assert caps["bearing"] == %{present: 1, of: 3}
106 + end
107 + end
108 +
109 + describe "trip updates" do
110 + test "skipped stops are counted over stops, cancellations over trips" do
111 + caps =
112 + FeedTester.capabilities(
113 + feed([
114 + trip_update([stu(schedule_relationship: :SKIPPED), stu([])],
115 + schedule_relationship: :SCHEDULED
116 + ),
117 + trip_update([stu([])], schedule_relationship: :CANCELED)
118 + ])
119 + )
120 +
121 + assert caps["skipped stops"] == %{present: 1, of: 3}
122 + assert caps["cancelled / added trips"] == %{present: 1, of: 2}
123 + end
124 +
125 + test "a trip saying nothing about its relationship is running as published" do
126 + caps = FeedTester.capabilities(feed([trip_update([stu([])], schedule_relationship: nil)]))
127 +
128 + assert caps["cancelled / added trips"] == %{present: 0, of: 1}
129 + end
130 +
131 + test "delay and uncertainty are counted over the time events" do
132 + with_delay = stu(arrival: %T.TripUpdate.StopTimeEvent{time: 1, delay: 60})
133 + with_unc = stu(departure: %T.TripUpdate.StopTimeEvent{time: 1, uncertainty: 30})
134 + bare = stu(arrival: %T.TripUpdate.StopTimeEvent{time: 1})
135 +
136 + caps = FeedTester.capabilities(feed([trip_update([with_delay, with_unc, bare])]))
137 +
138 + assert caps["delay"] == %{present: 1, of: 3}
139 + assert caps["uncertainty"] == %{present: 1, of: 3}
140 + end
141 +
142 + test "a track assignment and a per-stop headsign" do
143 + props = %T.TripUpdate.StopTimeUpdate.StopTimeProperties{
144 + assigned_stop_id: "127N",
145 + stop_headsign: ""
146 + }
147 +
148 + caps =
149 + FeedTester.capabilities(
150 + feed([trip_update([stu(stop_time_properties: props), stu([])])])
151 + )
152 +
153 + assert caps["track assignment"] == %{present: 1, of: 2}
154 + # An empty string is not a headsign.
155 + assert caps["per-stop headsign"] == %{present: 0, of: 2}
156 + end
157 + end
158 +
159 + describe "alerts" do
160 + defp translated(text), do: %T.TranslatedString{translation: [%T.TranslatedString.Translation{text: text, language: "en"}]}
161 +
162 + defp alert(attrs) do
163 + %T.FeedEntity{id: "e", alert: struct(%T.Alert{}, attrs)}
164 + end
165 +
166 + defp selector(attrs), do: struct(%T.EntitySelector{}, attrs)
167 +
168 + test "a headline the publisher actually filled in" do
169 + caps =
170 + FeedTester.capabilities(
171 + feed([
172 + alert(header_text: translated("Red Line delays")),
173 + alert(header_text: nil)
174 + ])
175 + )
176 +
177 + assert caps["alert headline"] == %{present: 1, of: 2}
178 + end
179 +
180 + test "an empty envelope is not a headline" do
181 + caps =
182 + FeedTester.capabilities(
183 + feed([
184 + alert(header_text: %T.TranslatedString{translation: []}),
185 + alert(header_text: translated(" "))
186 + ])
187 + )
188 +
189 + # A field wired up and never filled reads the same as one never wired up.
190 + assert caps["alert headline"] == %{present: 0, of: 2}
191 + end
192 +
193 + test "the proto defaults for cause, effect and severity mean nothing was said" do
194 + caps =
195 + FeedTester.capabilities(
196 + feed([
197 + alert(cause: :UNKNOWN_CAUSE, effect: :UNKNOWN_EFFECT, severity_level: :UNKNOWN_SEVERITY),
198 + alert(cause: :STRIKE, effect: :DETOUR, severity_level: :SEVERE)
199 + ])
200 + )
201 +
202 + assert caps["alert cause"] == %{present: 1, of: 2}
203 + assert caps["alert effect"] == %{present: 1, of: 2}
204 + assert caps["alert severity"] == %{present: 1, of: 2}
205 + end
206 +
207 + test "periods, links and images" do
208 + caps =
209 + FeedTester.capabilities(
210 + feed([
211 + alert(
212 + active_period: [%T.TimeRange{start: 1}],
213 + url: translated("https://example.test/a"),
214 + image: %T.TranslatedImage{localized_image: []}
215 + ),
216 + alert([])
217 + ])
218 + )
219 +
220 + assert caps["alert period"] == %{present: 1, of: 2}
221 + assert caps["alert link"] == %{present: 1, of: 2}
222 + assert caps["alert image"] == %{present: 1, of: 2}
223 + end
224 +
225 + test "what an alert is aimed at, counted over the things it names" do
226 + caps =
227 + FeedTester.capabilities(
228 + feed([
229 + alert(
230 + informed_entity: [
231 + selector(stop_id: "s1", route_id: "Red"),
232 + selector(route_id: "Orange"),
233 + selector(trip: %T.TripDescriptor{trip_id: "t1"}),
234 + selector(agency_id: "1")
235 + ]
236 + )
237 + ])
238 + )
239 +
240 + assert caps["targets stops"] == %{present: 1, of: 4}
241 + assert caps["targets routes"] == %{present: 2, of: 4}
242 + assert caps["targets trips"] == %{present: 1, of: 4}
243 + # Naming an agency and nothing else is an alert about everything.
244 + assert caps["agency-wide"] == %{present: 1, of: 4}
245 + end
246 +
247 + test "a selector naming nothing at all is agency-wide too" do
248 + caps = FeedTester.capabilities(feed([alert(informed_entity: [selector([])])]))
249 +
250 + assert caps["agency-wide"] == %{present: 1, of: 1}
251 + end
252 +
253 + test "an alert naming nothing is not asked what it targets" do
254 + caps = FeedTester.capabilities(feed([alert(header_text: translated("x"))]))
255 +
256 + assert Map.has_key?(caps, "alert headline")
257 + refute Map.has_key?(caps, "targets stops")
258 + end
259 + end
260 +
261 + describe "what is not there" do
262 + test "a feed with no alerts says nothing about alerts" do
263 + caps = FeedTester.capabilities(feed([trip_update([stu([])])]))
264 +
265 + refute Map.has_key?(caps, "alert headline")
266 + end
267 +
268 + test "a feed with no vehicles says nothing about vehicles" do
269 + caps = FeedTester.capabilities(feed([trip_update([stu([])])]))
270 +
271 + # Rather than 0 of 0 everywhere, which would read as a feed refusing to
272 + # give them.
273 + refute Map.has_key?(caps, "occupancy")
274 + refute Map.has_key?(caps, "congestion level")
275 + assert Map.has_key?(caps, "skipped stops")
276 + end
277 +
278 + test "an empty feed reports nothing at all" do
279 + assert FeedTester.capabilities(feed([])) == %{}
280 + end
281 + end
282 +end
added apps/room_gtfs/test/feed_tester_findings_test.exs
+117 −0
@@ -0,0 +1,117 @@
1 +defmodule RoomGtfs.FeedTesterFindingsTest do
2 + @moduledoc """
3 + The two ways a realtime feed can be wrong without saying so.
4 +
5 + Both decode cleanly, report plausible numbers, and quietly match nothing --
6 + which is the failure this tester exists to name.
7 + """
8 + use ExUnit.Case, async: true
9 +
10 + alias RoomGtfs.FeedTester
11 + alias TransitRealtime, as: T
12 +
13 + defp feed(entities, header_attrs \\ []) do
14 + %T.FeedMessage{
15 + header: struct(%T.FeedHeader{gtfs_realtime_version: "2.0"}, header_attrs),
16 + entity: entities
17 + }
18 + end
19 +
20 + defp trip_update(stus, attrs \\ []) do
21 + struct(
22 + %T.FeedEntity{
23 + id: "e",
24 + trip_update: %T.TripUpdate{
25 + trip: %T.TripDescriptor{trip_id: "t1"},
26 + stop_time_update: stus
27 + }
28 + },
29 + attrs
30 + )
31 + end
32 +
33 + defp stu(attrs), do: struct(%T.TripUpdate.StopTimeUpdate{}, attrs)
34 +
35 + defp messages(result), do: Enum.map(result.findings, fn {_sev, m} -> m end) |> Enum.join(" | ")
36 + defp severities(result), do: Enum.map(result.findings, fn {sev, _m} -> sev end)
37 +
38 + # The checks run over a decoded feed; fetching it and comparing it to the
39 + # static schedule are separate concerns, so these are exercised on their own.
40 + defp check(feed) do
41 + %{findings: []}
42 + |> FeedTester.check_incrementality(feed)
43 + |> FeedTester.check_stop_identification(feed)
44 + end
45 +
46 + describe "differential feeds" do
47 + test "a full dataset says nothing about it" do
48 + result = check(feed([trip_update([stu(stop_id: "s1")])], incrementality: :FULL_DATASET))
49 +
50 + refute messages(result) =~ "DIFFERENTIAL"
51 + end
52 +
53 + test "a differential feed is handled, so it is a note rather than a fault" do
54 + result = check(feed([trip_update([stu(stop_id: "s1")])], incrementality: :DIFFERENTIAL))
55 +
56 + assert messages(result) =~ "differential"
57 + assert messages(result) =~ "held between polls"
58 + assert :info in severities(result)
59 + refute :error in severities(result)
60 + end
61 +
62 + test "it says the count on the page is a delta, not a total" do
63 + entities = [
64 + trip_update([stu(stop_id: "s1")], is_deleted: true),
65 + trip_update([stu(stop_id: "s1")])
66 + ]
67 +
68 + result = check(feed(entities, incrementality: :DIFFERENTIAL))
69 +
70 + assert messages(result) =~ "one of them a deletion"
71 + assert messages(result) =~ "not the whole feed"
72 + end
73 +
74 + test "deletions in a feed calling itself whole are still worth flagging" do
75 + entities = [trip_update([stu(stop_id: "s1")], is_deleted: true)]
76 +
77 + result = check(feed(entities, incrementality: :FULL_DATASET))
78 +
79 + assert messages(result) =~ "marked deleted"
80 + assert :warn in severities(result)
81 + end
82 + end
83 +
84 + describe "stops named only by position" do
85 + test "a feed carrying stop ids is fine" do
86 + result = check(feed([trip_update([stu(stop_id: "s1", stop_sequence: 4)])]))
87 +
88 + refute messages(result) =~ "position in the trip"
89 + end
90 +
91 + test "no stop ids anywhere is an error: nothing will ever match" do
92 + result = check(feed([trip_update([stu(stop_sequence: 4), stu(stop_sequence: 5)])]))
93 +
94 + assert messages(result) =~ "never by stop id"
95 + assert :error in severities(result)
96 + end
97 +
98 + test "some missing is a warning naming how many" do
99 + result = check(feed([trip_update([stu(stop_id: "s1"), stu(stop_sequence: 5)])]))
100 +
101 + assert messages(result) =~ "1 of 2 stop updates carry no stop id"
102 + assert :warn in severities(result)
103 + end
104 +
105 + test "an empty string is not a stop id" do
106 + result = check(feed([trip_update([stu(stop_id: "", stop_sequence: 5)])]))
107 +
108 + assert messages(result) =~ "never by stop id"
109 + end
110 +
111 + test "a feed with no trip updates is not asked about stops" do
112 + vehicle = %T.FeedEntity{id: "e", vehicle: %T.VehiclePosition{}}
113 +
114 + refute messages(check(feed([vehicle]))) =~ "position in the trip"
115 + end
116 + end
117 +end
added apps/room_gtfs/test/occupancy_test.exs
+169 −0

Click to load diff…

modified apps/room_sanctum/assets/js/hooks/leaflet_map.js
+10 −4

Click to load diff…

modified apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+26 −4

Click to load diff…

modified apps/room_sanctum/assets/js/leaflet/leaflet-marker.js
+1 −1

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+42 −0

Click to load diff…

added apps/room_sanctum/lib/room_sanctum/condensers/plus_mqtt.ex
+141 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum/configuration.ex
+17 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum/storage.ex
+42 −6

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+7 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/public.ex
+6 −1

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/public.html.heex
+6 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/show.ex
+20 −3

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_pythiae_live/show.html.heex
+46 −12

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+19 −3

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+27 −11

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+30 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+21 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/show.ex
+129 −5

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/show.html.heex
+90 −15

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/helpers/map_data.ex
+1 −1

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/live_preview.ex
+356 −4

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/router.ex
+5 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum/basic_condenser_test.exs
+113 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum/plus_condenser_test.exs
+194 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum_web/components/occupancy_test.exs
+210 −0

Click to load diff…

modified apps/room_sanctum/test/room_sanctum_web/components/vehicle_popup_test.exs
+41 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum_web/live/add_vision_to_pythiae_test.exs
+176 −0

Click to load diff…

modified apps/room_sanctum/test/room_sanctum_web/live/keryx_live_test.exs
+5 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum_web/live/pythiae_public_plus_test.exs
+66 −0

Click to load diff…

modified apps/room_sanctum/test/room_sanctum_web/live/vision_preview_test.exs
+62 −3

Click to load diff…

Parents: ec561a7