neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
GTFS: optional fleet marks, so new trains show up in the Plus feed
5ac303a · Gabriel Morell · 2026-10-14 23:08
Message
{commit_body(@commit)}
Files changed
added
apps/room_gtfs/lib/room_gtfs/fleet.ex
+80
−0
@@ -0,0 +1,80 @@
| 1 | +defmodule RoomGtfs.Fleet do | |
| 2 | + @moduledoc """ | |
| 3 | + Whether a vehicle is one of the ones a source has asked to have called out. | |
| 4 | + | |
| 5 | + The rules are a source's `fleet_marks`: route id to inclusive car-number | |
| 6 | + ranges. A train is marked when any of its cars is in range for the route it | |
| 7 | + is running on, which is how the new-train-tracker reads a mixed consist -- | |
| 8 | + one new car is a new train to someone looking for it. | |
| 9 | + | |
| 10 | + Car numbers come from the feed's own per-carriage labels where it sends | |
| 11 | + them, and otherwise from the vehicle's label, which the MBTA writes as the | |
| 12 | + cars joined by hyphens: "1877-1876". A label that is not a number is no car | |
| 13 | + of anyone's fleet and simply never matches. | |
| 14 | + """ | |
| 15 | + | |
| 16 | + @doc """ | |
| 17 | + The rules for a route, or nil where there are none -- so a source that set | |
| 18 | + none, or a route it did not mention, costs nothing further. | |
| 19 | + """ | |
| 20 | + def rules_for(config, route_id) when is_binary(route_id) do | |
| 21 | + case config && Map.get(config, :fleet_marks) do | |
| 22 | + marks when is_map(marks) and map_size(marks) > 0 -> | |
| 23 | + case Map.get(marks, route_id) do | |
| 24 | + ranges when is_list(ranges) and ranges != [] -> ranges | |
| 25 | + _ -> nil | |
| 26 | + end | |
| 27 | + | |
| 28 | + _ -> | |
| 29 | + nil | |
| 30 | + end | |
| 31 | + end | |
| 32 | + | |
| 33 | + def rules_for(_config, _route_id), do: nil | |
| 34 | + | |
| 35 | + @doc "Whether a car label falls in any of the ranges." | |
| 36 | + def car_marked?(label, ranges) do | |
| 37 | + case car_number(label) do | |
| 38 | + nil -> false | |
| 39 | + n -> Enum.any?(ranges, fn [low, high] -> n >= low and n <= high end) | |
| 40 | + end | |
| 41 | + end | |
| 42 | + | |
| 43 | + @doc """ | |
| 44 | + The car labels of a vehicle as `place_vehicle/2` records it: its carriages' | |
| 45 | + labels if it reported any, else its own label split into cars. | |
| 46 | + """ | |
| 47 | + def car_labels(vehicle) do | |
| 48 | + carriage_labels = | |
| 49 | + vehicle | |
| 50 | + |> Map.get(:carriages) | |
| 51 | + |> List.wrap() | |
| 52 | + |> Enum.map(& &1.label) | |
| 53 | + |> Enum.filter(&(is_binary(&1) and &1 != "")) | |
| 54 | + | |
| 55 | + case carriage_labels do | |
| 56 | + [] -> | |
| 57 | + case Map.get(vehicle, :label) do | |
| 58 | + label when is_binary(label) -> label |> String.split("-") |> Enum.map(&String.trim/1) | |
| 59 | + _ -> [] | |
| 60 | + end | |
| 61 | + | |
| 62 | + labels -> | |
| 63 | + labels | |
| 64 | + end | |
| 65 | + end | |
| 66 | + | |
| 67 | + @doc "Whether any of a vehicle's cars is in range." | |
| 68 | + def vehicle_marked?(vehicle, ranges) do | |
| 69 | + vehicle |> car_labels() |> Enum.any?(&car_marked?(&1, ranges)) | |
| 70 | + end | |
| 71 | + | |
| 72 | + defp car_number(label) when is_binary(label) do | |
| 73 | + case Integer.parse(String.trim(label)) do | |
| 74 | + {n, ""} -> n | |
| 75 | + _ -> nil | |
| 76 | + end | |
| 77 | + end | |
| 78 | + | |
| 79 | + defp car_number(_label), do: nil | |
| 80 | +end |
modified
apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+39
−4
@@ -320,6 +320,7 @@ defmodule RoomGtfs.Worker do
| 320 | 320 | x = |
| 321 | 321 | x |
| 322 | 322 | |> merge_occupancy(occupancy_for(config, x.trip_id, stu, vehicles)) |
| 323 | + |> merge_fleet_mark(config, vehicles) | |
| 323 | 324 | |> merge_schedule_relationship(val.schedule_relationship, stu) |
| 324 | 325 | |> merge_stop_properties(stu) |
| 325 | 326 |
@@ -402,10 +403,7 @@ defmodule RoomGtfs.Worker do
| 402 | 403 | # answer, so it reads the same as an absent field -- NOT_ACCEPTING_PASSENGERS |
| 403 | 404 | # is a real answer and is kept. |
| 404 | 405 | def occupancy_for(config, scheduled_trip_id, stu, vehicles) do |
| 405 | − vehicle = | |
| 406 | − Enum.find(vehicles, fn v -> | |
| 407 | − v.trip_id && trip_id_match?(config, scheduled_trip_id, v.trip_id) | |
| 408 | − end) | |
| 406 | + vehicle = vehicle_for(config, scheduled_trip_id, vehicles) | |
| 409 | 407 | |
| 410 | 408 | status = |
| 411 | 409 | occupancy_status(stu && stu.departure_occupancy_status) || |
@@ -426,6 +424,41 @@ defmodule RoomGtfs.Worker do
| 426 | 424 | end |
| 427 | 425 | end |
| 428 | 426 | |
| 427 | + defp vehicle_for(config, scheduled_trip_id, vehicles) do | |
| 428 | + Enum.find(vehicles, fn v -> | |
| 429 | + v.trip_id && trip_id_match?(config, scheduled_trip_id, v.trip_id) | |
| 430 | + end) | |
| 431 | + end | |
| 432 | + | |
| 433 | + @doc false | |
| 434 | + # Whether this train is one the source asked to have called out -- see | |
| 435 | + # RoomGtfs.Fleet. Only a source with rules for this route gets the keys at | |
| 436 | + # all, and only once a vehicle is running the trip: before then there is no | |
| 437 | + # telling which train it will be, which is not the same as "not a new one". | |
| 438 | + def merge_fleet_mark(arrival, config, vehicles) do | |
| 439 | + trip = Map.get(arrival, :trip) | |
| 440 | + route_id = trip && Map.get(trip, :route_id) | |
| 441 | + | |
| 442 | + with ranges when ranges != nil <- RoomGtfs.Fleet.rules_for(config, route_id), | |
| 443 | + vehicle when vehicle != nil <- vehicle_for(config, arrival.trip_id, vehicles) do | |
| 444 | + arrival = Map.put(arrival, :marked, RoomGtfs.Fleet.vehicle_marked?(vehicle, ranges)) | |
| 445 | + | |
| 446 | + case Map.get(arrival, :carriages) do | |
| 447 | + nil -> | |
| 448 | + arrival | |
| 449 | + | |
| 450 | + carriages -> | |
| 451 | + Map.put( | |
| 452 | + arrival, | |
| 453 | + :carriages, | |
| 454 | + Enum.map(carriages, &Map.put(&1, :marked, RoomGtfs.Fleet.car_marked?(&1.label, ranges))) | |
| 455 | + ) | |
| 456 | + end | |
| 457 | + else | |
| 458 | + _ -> arrival | |
| 459 | + end | |
| 460 | + end | |
| 461 | + | |
| 429 | 462 | @doc false |
| 430 | 463 | # A train reporting each carriage separately is the difference between "the |
| 431 | 464 | # train is full" and "walk to the back". Kept in the sequence the feed gives, |
@@ -1101,6 +1134,8 @@ defmodule RoomGtfs.Worker.RT do
| 1101 | 1134 | defp place_vehicle(v, stops) do |
| 1102 | 1135 | base = %{ |
| 1103 | 1136 | vehicle_id: v.vehicle && v.vehicle.id, |
| 1137 | + # What is painted on it, which for the MBTA is the car numbers. | |
| 1138 | + label: v.vehicle && v.vehicle.label, | |
| 1104 | 1139 | trip_id: v.trip && v.trip.trip_id, |
| 1105 | 1140 | route_id: v.trip && v.trip.route_id, |
| 1106 | 1141 | stop_id: v.stop_id, |
added
apps/room_gtfs/test/fleet_test.exs
+61
−0
@@ -0,0 +1,61 @@
| 1 | +defmodule RoomGtfs.FleetTest do | |
| 2 | + use ExUnit.Case, async: true | |
| 3 | + | |
| 4 | + alias RoomGtfs.{Fleet, Worker} | |
| 5 | + | |
| 6 | + @mbta %{ | |
| 7 | + fleet_marks: %{ | |
| 8 | + "Red" => [[1900, 2151]], | |
| 9 | + "Green-D" => [[3900, 3923], [4001, 4102]] | |
| 10 | + } | |
| 11 | + } | |
| 12 | + | |
| 13 | + defp arrival(route_id, attrs \\ []) do | |
| 14 | + Map.merge(%{trip_id: "t1", trip: %{route_id: route_id}}, Map.new(attrs)) | |
| 15 | + end | |
| 16 | + | |
| 17 | + defp vehicle(attrs) do | |
| 18 | + Map.merge(%{trip_id: "t1", label: nil, carriages: []}, Map.new(attrs)) | |
| 19 | + end | |
| 20 | + | |
| 21 | + test "one new car makes a new train" do | |
| 22 | + v = vehicle(label: "1877-1910") | |
| 23 | + | |
| 24 | + assert %{marked: true} = Worker.merge_fleet_mark(arrival("Red"), @mbta, [v]) | |
| 25 | + end | |
| 26 | + | |
| 27 | + test "an old train is marked false, not left unsaid" do | |
| 28 | + v = vehicle(label: "1500-1501") | |
| 29 | + | |
| 30 | + assert %{marked: false} = Worker.merge_fleet_mark(arrival("Red"), @mbta, [v]) | |
| 31 | + end | |
| 32 | + | |
| 33 | + test "carriage labels are preferred and each is marked" do | |
| 34 | + v = | |
| 35 | + vehicle( | |
| 36 | + label: "1877", | |
| 37 | + carriages: [%{label: "1877"}, %{label: "4010"}] | |
| 38 | + ) | |
| 39 | + | |
| 40 | + a = | |
| 41 | + arrival("Green-D", carriages: [%{label: "1877"}, %{label: "4010"}]) | |
| 42 | + |> Worker.merge_fleet_mark(@mbta, [v]) | |
| 43 | + | |
| 44 | + assert a.marked | |
| 45 | + assert [%{marked: false}, %{marked: true}] = a.carriages | |
| 46 | + end | |
| 47 | + | |
| 48 | + test "a route without rules, a source without rules, and no vehicle all say nothing" do | |
| 49 | + v = vehicle(label: "1910") | |
| 50 | + | |
| 51 | + refute Map.has_key?(Worker.merge_fleet_mark(arrival("Blue"), @mbta, [v]), :marked) | |
| 52 | + refute Map.has_key?(Worker.merge_fleet_mark(arrival("Red"), %{}, [v]), :marked) | |
| 53 | + refute Map.has_key?(Worker.merge_fleet_mark(arrival("Red"), @mbta, []), :marked) | |
| 54 | + end | |
| 55 | + | |
| 56 | + test "a label that is not a number matches nothing" do | |
| 57 | + refute Fleet.car_marked?("19xx", [[1900, 2151]]) | |
| 58 | + refute Fleet.car_marked?(nil, [[1900, 2151]]) | |
| 59 | + assert Fleet.car_marked?(" 2151 ", [[1900, 2151]]) | |
| 60 | + end | |
| 61 | +end |
modified
apps/room_sanctum/lib/room_sanctum/condensers/plus_mqtt.ex
+4
−0
@@ -39,6 +39,10 @@ defmodule RoomSanctum.Condenser.PlusMQTT do
| 39 | 39 | name: f.trip.trip_short_name, |
| 40 | 40 | bikes: f.trip.bikes_allowed, |
| 41 | 41 | carriages: Map.get(f, :carriages, []), |
| 42 | + # One of the vehicles the source's fleet marks call out -- true, | |
| 43 | + # false, or nil where the source set none for this route or no | |
| 44 | + # vehicle has been seen on the trip yet. | |
| 45 | + marked: Map.get(f, :marked), | |
| 42 | 46 | # Not running, or running but not calling here -- kept apart, |
| 43 | 47 | # because they are different news to a rider. |
| 44 | 48 | trip_status: Map.get(f, :trip_status), |
modified
apps/room_sanctum/lib/room_sanctum/configuration/configs/gtfs.ex
+131
−2
@@ -62,23 +62,152 @@ defmodule RoomSanctum.Configuration.Configs.GTFS do
| 62 | 62 | # What the form edits: one `Name: value` per line. The stored shape is the |
| 63 | 63 | # map; this is the shape a person can type and read back. |
| 64 | 64 | field :headers_raw, :string, virtual: true |
| 65 | + | |
| 66 | + # Vehicles worth calling out, by route and car number: route id to a list | |
| 67 | + # of inclusive [low, high] ranges. An arrival whose train has any car in | |
| 68 | + # range is marked in the Plus feed, and so is each such car. Written for | |
| 69 | + # the MBTA's new fleets -- see mbta_new_trains_text/0 -- but nothing in it | |
| 70 | + # is specific to them. | |
| 71 | + # | |
| 72 | + # Empty, which is every source that has not asked, marks nothing. | |
| 73 | + field :fleet_marks, :map, default: %{} | |
| 74 | + | |
| 75 | + # The form's shape of the same: one `Route, Route: low-high, low-high` per | |
| 76 | + # line, as with the headers. | |
| 77 | + field :fleet_marks_raw, :string, virtual: true | |
| 65 | 78 | end |
| 66 | 79 | |
| 80 | + # The rules transitmatters' new-train-tracker uses, in its server's | |
| 81 | + # chalicelib/fleet.py: CRRC cars on Red and Orange, Type 9 and 10 on the | |
| 82 | + # Green Line, battery buses on the Silver Line. The tracker splits Red into | |
| 83 | + # its two branches; the MBTA's own feed calls both of them Red. | |
| 84 | + @mbta_new_trains """ | |
| 85 | + Red: 1900-2151 | |
| 86 | + Orange: 1400-1551 | |
| 87 | + Green-B, Green-C, Green-D, Green-E: 3900-3923, 4001-4102 | |
| 88 | + 741, 742, 743, 746, 749, 751: 1294-1299 | |
| 89 | + """ | |
| 90 | + | |
| 67 | 91 | def changeset(source, params) do |
| 68 | 92 | source |
| 69 | 93 | |> cast( |
| 70 | 94 | params, |
| 71 | 95 | ~w(url url_rt_sa url_rt_tu url_rt_vp url_rt_shared tz rt_agency |
| 72 | − rt_trip_id_suffix rt_period_tu rt_period_vp rt_period_sa headers)a | |
| 96 | + rt_trip_id_suffix rt_period_tu rt_period_vp rt_period_sa headers fleet_marks)a | |
| 73 | 97 | ) |
| 74 | 98 | # Its own cast, with "" kept as a value rather than treated as nothing. |
| 75 | 99 | # Ecto's default empty_values turns an emptied textarea into no change at |
| 76 | 100 | # all, so clearing the box left the old headers in place -- which means an |
| 77 | 101 | # API key could be added through this form and never taken away again. |
| 78 | − |> cast(params, [:headers_raw], empty_values: []) | |
| 102 | + |> cast(params, [:headers_raw, :fleet_marks_raw], empty_values: []) | |
| 79 | 103 | |> validate_required([:url, :tz]) |
| 80 | 104 | |> validate_periods() |
| 81 | 105 | |> put_headers() |
| 106 | + |> put_fleet_marks() | |
| 107 | + end | |
| 108 | + | |
| 109 | + @doc "The MBTA's new-train rules, as the form's text." | |
| 110 | + def mbta_new_trains_text, do: String.trim_trailing(@mbta_new_trains) | |
| 111 | + | |
| 112 | + @doc """ | |
| 113 | + The stored fleet marks as the text the form edits. | |
| 114 | + | |
| 115 | + Routes sharing the same ranges are written on one line, so the MBTA rules | |
| 116 | + read back as the four lines they went in as rather than eleven. | |
| 117 | + """ | |
| 118 | + def fleet_marks_text(%{fleet_marks: marks}) when is_map(marks) and map_size(marks) > 0 do | |
| 119 | + marks | |
| 120 | + |> Enum.group_by(fn {_route, ranges} -> ranges end, fn {route, _} -> route end) | |
| 121 | + |> Enum.map(fn {ranges, routes} -> {Enum.sort(routes), ranges} end) | |
| 122 | + |> Enum.sort() | |
| 123 | + |> Enum.map_join("\n", fn {routes, ranges} -> | |
| 124 | + Enum.join(routes, ", ") <> ": " <> Enum.map_join(ranges, ", ", &range_text/1) | |
| 125 | + end) | |
| 126 | + end | |
| 127 | + | |
| 128 | + def fleet_marks_text(_config), do: "" | |
| 129 | + | |
| 130 | + defp range_text([low, low]), do: to_string(low) | |
| 131 | + defp range_text([low, high]), do: "#{low}-#{high}" | |
| 132 | + | |
| 133 | + @doc """ | |
| 134 | + Parse the fleet marks textarea into route id => [[low, high], ...], or say | |
| 135 | + which line is wrong. | |
| 136 | + | |
| 137 | + A line naming a route already named adds to it rather than replacing it. | |
| 138 | + """ | |
| 139 | + def parse_fleet_marks(text) when is_binary(text) do | |
| 140 | + text | |
| 141 | + |> String.split(~r/\r?\n/) | |
| 142 | + |> Enum.map(&String.trim/1) | |
| 143 | + |> Enum.reject(&(&1 == "")) | |
| 144 | + |> Enum.reduce_while({:ok, %{}}, fn line, {:ok, acc} -> | |
| 145 | + with [routes, ranges] <- String.split(line, ":", parts: 2), | |
| 146 | + routes = split_list(routes), | |
| 147 | + true <- routes != [], | |
| 148 | + {:ok, ranges} <- parse_ranges(ranges) do | |
| 149 | + acc = | |
| 150 | + Enum.reduce(routes, acc, fn route, acc -> | |
| 151 | + Map.update(acc, route, ranges, &Enum.uniq(&1 ++ ranges)) | |
| 152 | + end) | |
| 153 | + | |
| 154 | + {:cont, {:ok, acc}} | |
| 155 | + else | |
| 156 | + _ -> {:halt, {:error, line}} | |
| 157 | + end | |
| 158 | + end) | |
| 159 | + end | |
| 160 | + | |
| 161 | + def parse_fleet_marks(_text), do: {:ok, %{}} | |
| 162 | + | |
| 163 | + defp split_list(text) do | |
| 164 | + text |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) | |
| 165 | + end | |
| 166 | + | |
| 167 | + defp parse_ranges(text) do | |
| 168 | + case split_list(text) do | |
| 169 | + [] -> | |
| 170 | + :error | |
| 171 | + | |
| 172 | + parts -> | |
| 173 | + Enum.reduce_while(parts, {:ok, []}, fn part, {:ok, acc} -> | |
| 174 | + case parse_range(part) do | |
| 175 | + {:ok, range} -> {:cont, {:ok, acc ++ [range]}} | |
| 176 | + :error -> {:halt, :error} | |
| 177 | + end | |
| 178 | + end) | |
| 179 | + end | |
| 180 | + end | |
| 181 | + | |
| 182 | + defp parse_range(part) do | |
| 183 | + case Regex.run(~r/^(\d+)\s*(?:-\s*(\d+))?$/, part) do | |
| 184 | + [_, low] -> {:ok, [String.to_integer(low), String.to_integer(low)]} | |
| 185 | + [_, low, high] -> ordered(String.to_integer(low), String.to_integer(high)) | |
| 186 | + _ -> :error | |
| 187 | + end | |
| 188 | + end | |
| 189 | + | |
| 190 | + defp ordered(low, high) when low <= high, do: {:ok, [low, high]} | |
| 191 | + defp ordered(_low, _high), do: :error | |
| 192 | + | |
| 193 | + defp put_fleet_marks(changeset) do | |
| 194 | + case fetch_change(changeset, :fleet_marks_raw) do | |
| 195 | + :error -> | |
| 196 | + changeset | |
| 197 | + | |
| 198 | + {:ok, text} -> | |
| 199 | + case parse_fleet_marks(text) do | |
| 200 | + {:ok, marks} -> | |
| 201 | + put_change(changeset, :fleet_marks, marks) | |
| 202 | + | |
| 203 | + {:error, line} -> | |
| 204 | + add_error( | |
| 205 | + changeset, | |
| 206 | + :fleet_marks_raw, | |
| 207 | + "expected \"Route, Route: low-high, low-high\", got: #{line}" | |
| 208 | + ) | |
| 209 | + end | |
| 210 | + end | |
| 82 | 211 | end |
| 83 | 212 | |
| 84 | 213 | @doc """ |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/form_component.html.heex
+20
−0
@@ -153,6 +153,26 @@
| 153 | 153 | subway sends <code>098600_5..S03R</code> where the schedule says |
| 154 | 154 | <code>ASP26GEN-1038-Sunday-00_098600_5..S03R</code>. Leave off unless the |
| 155 | 155 | feed tester says otherwise; matching on a suffix is looser than matching |
| 156 | + <div class="form-control"> | |
| 157 | + <.input | |
| 158 | + field={gtfs_form[:fleet_marks_raw]} | |
| 159 | + type="textarea" | |
| 160 | + label="Fleet marks" | |
| 161 | + rows="4" | |
| 162 | + value={gtfs_form[:fleet_marks_raw].value || | |
| 163 | + RoomSanctum.Configuration.Configs.GTFS.fleet_marks_text(gtfs_form.data)} | |
| 164 | + placeholder={RoomSanctum.Configuration.Configs.GTFS.mbta_new_trains_text()} | |
| 165 | + /> | |
| 166 | + <label class="label"> | |
| 167 | + <span class="label-text-alt opacity-70"> | |
| 168 | + Optional. One <code>Route, Route: low-high, low-high</code> per line, by | |
| 169 | + route id and car number. An arrival whose train has any car in range is | |
| 170 | + marked <code>marked: true</code> in the Plus feed, as is each such car. | |
| 171 | + Needs a vehicle positions feed. The placeholder is the MBTA's new | |
| 172 | + trains, as transitmatters' new-train-tracker counts them. | |
| 173 | + </span> | |
| 174 | + </label> | |
| 175 | + </div> | |
| 156 | 176 | exactly. |
| 157 | 177 | </span> |
| 158 | 178 | </label> |
added
apps/room_sanctum/test/room_sanctum/gtfs_fleet_marks_test.exs
+55
−0
@@ -0,0 +1,55 @@
| 1 | +defmodule RoomSanctum.GtfsFleetMarksTest do | |
| 2 | + use ExUnit.Case, async: true | |
| 3 | + | |
| 4 | + alias Ecto.Changeset | |
| 5 | + alias RoomSanctum.Configuration.Configs.GTFS | |
| 6 | + | |
| 7 | + defp change(attrs) do | |
| 8 | + GTFS.changeset(%GTFS{}, Map.merge(%{"url" => "http://x/gtfs.zip", "tz" => "UTC"}, attrs)) | |
| 9 | + end | |
| 10 | + | |
| 11 | + test "the MBTA preset parses, a route list expanding to each route" do | |
| 12 | + assert {:ok, marks} = GTFS.parse_fleet_marks(GTFS.mbta_new_trains_text()) | |
| 13 | + | |
| 14 | + assert marks["Red"] == [[1900, 2151]] | |
| 15 | + assert marks["Green-E"] == [[3900, 3923], [4001, 4102]] | |
| 16 | + assert marks["749"] == [[1294, 1299]] | |
| 17 | + assert map_size(marks) == 12 | |
| 18 | + end | |
| 19 | + | |
| 20 | + test "it reads back as the lines it went in as" do | |
| 21 | + {:ok, marks} = GTFS.parse_fleet_marks(GTFS.mbta_new_trains_text()) | |
| 22 | + text = GTFS.fleet_marks_text(%{fleet_marks: marks}) | |
| 23 | + | |
| 24 | + assert {:ok, ^marks} = GTFS.parse_fleet_marks(text) | |
| 25 | + assert length(String.split(text, "\n")) == 4 | |
| 26 | + end | |
| 27 | + | |
| 28 | + test "a single car number is a range of one" do | |
| 29 | + assert {:ok, %{"Red" => [[1901, 1901]]}} = GTFS.parse_fleet_marks("Red: 1901") | |
| 30 | + assert GTFS.fleet_marks_text(%{fleet_marks: %{"Red" => [[1901, 1901]]}}) == "Red: 1901" | |
| 31 | + end | |
| 32 | + | |
| 33 | + test "malformed lines are refused" do | |
| 34 | + for line <- ["Red", "Red:", ": 1-2", "Red: 9-1", "Red: abc"] do | |
| 35 | + assert {:error, ^line} = GTFS.parse_fleet_marks(line) | |
| 36 | + end | |
| 37 | + end | |
| 38 | + | |
| 39 | + test "the changeset stores the map, refuses a bad line, and leaves it alone when unmentioned" do | |
| 40 | + assert %{"Red" => [[1, 2]]} = | |
| 41 | + change(%{"fleet_marks_raw" => "Red: 1-2"}) |> Changeset.get_field(:fleet_marks) | |
| 42 | + | |
| 43 | + refute change(%{"fleet_marks_raw" => "nope"}).valid? | |
| 44 | + | |
| 45 | + existing = %GTFS{fleet_marks: %{"Red" => [[1, 2]]}} | |
| 46 | + | |
| 47 | + assert %{"Red" => [[1, 2]]} = | |
| 48 | + GTFS.changeset(existing, %{"url" => "http://x", "tz" => "UTC"}) | |
| 49 | + |> Changeset.get_field(:fleet_marks) | |
| 50 | + | |
| 51 | + assert %{} == | |
| 52 | + GTFS.changeset(existing, %{"url" => "http://x", "tz" => "UTC", "fleet_marks_raw" => ""}) | |
| 53 | + |> Changeset.get_field(:fleet_marks) | |
| 54 | + end | |
| 55 | +end |
Parents: edb5c66