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.
Read GBFS v3 feeds
b52e7fa · Gabriel Morell · 2026-09-04 15:46
Message
{commit_body(@commit)}
Files changed
modified
?
+0
−0
Binary file — not rendered.
modified
?
+0
−0
Binary file — not rendered.
modified
apps/room_gbfs/lib/room_gbfs/gbfs_worker.ex
+49
−33
| 106 | 106 | |> Map.put(:updated_at, dt) |
| 107 | 107 | end |
| 108 | 108 | |
| 109 | − defp write_data(url, type, id) do | |
| 109 | + # `lang` is only used to pick one string out of v3's localised name lists; | |
| 110 | + # a v2 feed never reaches that code. | |
| 111 | + defp write_data(url, type, id, lang) do | |
| 110 | 112 | dt = NaiveDateTime.local_now() |
| 111 | 113 | Logger.info("GBFS::#{id} retrieving #{url}") |
| 112 | 114 |
| 123 | 125 | Repo.insert( |
| 124 | 126 | RoomSanctum.Storage.change_sys_info( |
| 125 | 127 | %RoomSanctum.Storage.GBFS.V1.SysInfo{}, |
| 126 | − json.data |> Map.put(:source_id, id |> intify) | |
| 128 | + json.data | |
| 129 | + |> RoomGbfs.V3.system_information(lang) | |
| 130 | + |> Map.put(:source_id, id |> intify) | |
| 127 | 131 | ), |
| 128 | 132 | on_conflict: {:replace_all_except, [:id]}, |
| 129 | 133 | conflict_target: [:source_id, :system_id] |
| 132 | 136 | :stat_info -> |
| 133 | 137 | data = |
| 134 | 138 | json.data.stations |
| 139 | + |> Enum.map(&RoomGbfs.V3.station_info(&1, lang)) | |
| 135 | 140 | |> Enum.map(fn x -> inj_iddt(x, id, dt) end) |
| 136 | 141 | |> Enum.map(fn x -> |
| 137 | 142 | point = %Geo.Point{coordinates: {x.lon, x.lat}, srid: 4326} |
| 155 | 160 | :stat_status -> |
| 156 | 161 | data = |
| 157 | 162 | json.data.stations |
| 163 | + |> Enum.map(&RoomGbfs.V3.station_status/1) | |
| 158 | 164 | |> Enum.map(fn x -> inj_iddt(x, id, dt) end) |
| 159 | 165 | |> Enum.map(fn x -> |
| 160 | 166 | RoomSanctum.Storage.change_station_status( |
| 173 | 179 | |
| 174 | 180 | :free_bike -> |
| 175 | 181 | data = |
| 176 | − json.data.bikes | |
| 182 | + # v3 calls them vehicles and puts them under `vehicles`. | |
| 183 | + (Map.get(json.data, :bikes) || Map.get(json.data, :vehicles) || []) | |
| 184 | + |> Enum.map(&RoomGbfs.V3.vehicle_status/1) | |
| 177 | 185 | |> Enum.map(fn x -> inj_iddt(x, id, dt) end) |
| 178 | 186 | |> Enum.map(fn x -> |
| 179 | 187 | point = %Geo.Point{coordinates: {x.lon, x.lat}, srid: 4326} |
| 199 | 207 | :vehicle_types -> |
| 200 | 208 | data = |
| 201 | 209 | json.data.vehicle_types |
| 210 | + |> Enum.map(&RoomGbfs.V3.vehicle_types(&1, lang)) | |
| 202 | 211 | |> Enum.map(fn x -> inj_iddt(x, id, dt) end) |
| 203 | 212 | |> Enum.map(fn x -> |
| 204 | 213 | RoomSanctum.Storage.change_vehicle_types( |
| 359 | 368 | %{} |
| 360 | 369 | end |
| 361 | 370 | |
| 362 | − if json_body["data"] != nil and | |
| 363 | − json_body["data"] | |
| 364 | − |> Map.has_key?(cfg.config.lang) do | |
| 365 | − bcast(state.id, :parsing, 2, @tfh) | |
| 371 | + case RoomGbfs.V3.feeds(json_body, cfg.config.lang) do | |
| 372 | + {:ok, version, feeds} -> | |
| 373 | + if version == :v3 do | |
| 374 | + Logger.info("GBFS::#{state.id} discovery is v3") | |
| 375 | + end | |
| 376 | + | |
| 377 | + bcast(state.id, :parsing, 2, @tfh) | |
| 366 | 378 | |
| 367 | − json_body["data"][cfg.config.lang]["feeds"] | |
| 368 | − |> Enum.map(fn %{"name" => name, "url" => url} -> | |
| 369 | − case name do | |
| 379 | + feeds | |
| 380 | + |> Enum.map(fn %{"name" => name, "url" => url} -> | |
| 381 | + # v3 renamed the loose-vehicle feed; everything below is | |
| 382 | + # written in v2's names, which is the one dialect this worker | |
| 383 | + # speaks. | |
| 384 | + case RoomGbfs.V3.feed_name(name) do | |
| 370 | 385 | "ebikes_at_stations" -> |
| 371 | − write_data(url, :ebikes_at_stations, state.id) | |
| 386 | + write_data(url, :ebikes_at_stations, state.id, cfg.config.lang) | |
| 372 | 387 | bcast(state.id, :system_information, 3, @tfh) |
| 373 | 388 | |
| 374 | 389 | "system_information" -> |
| 375 | − write_data(url, :sys_info, state.id) | |
| 390 | + write_data(url, :sys_info, state.id, cfg.config.lang) | |
| 376 | 391 | bcast(state.id, :system_information, 4, @tfh) |
| 377 | 392 | |
| 378 | 393 | "station_information" -> |
| 379 | − write_data(url, :stat_info, state.id) | |
| 394 | + write_data(url, :stat_info, state.id, cfg.config.lang) | |
| 380 | 395 | bcast(state.id, :system_information, 5, @tfh) |
| 381 | 396 | |
| 382 | 397 | "station_status" -> |
| 383 | − write_data(url, :stat_status, state.id) | |
| 398 | + write_data(url, :stat_status, state.id, cfg.config.lang) | |
| 384 | 399 | bcast(state.id, :system_information, 6, @tfh) |
| 385 | 400 | |
| 386 | 401 | "free_bike_status" -> |
| 387 | − write_data(url, :free_bike, state.id) | |
| 402 | + write_data(url, :free_bike, state.id, cfg.config.lang) | |
| 388 | 403 | bcast(state.id, :system_information, 7, @tfh) |
| 389 | 404 | |
| 390 | 405 | "system_hours" -> |
| 391 | − write_data(url, :sys_hours, state.id) | |
| 406 | + write_data(url, :sys_hours, state.id, cfg.config.lang) | |
| 392 | 407 | bcast(state.id, :system_information, 8, @tfh) |
| 393 | 408 | |
| 394 | 409 | "system_calendar" -> |
| 395 | − write_data(url, :sys_cal, state.id) | |
| 410 | + write_data(url, :sys_cal, state.id, cfg.config.lang) | |
| 396 | 411 | bcast(state.id, :system_information, 9, @tfh) |
| 397 | 412 | |
| 398 | 413 | "system_regions" -> |
| 399 | − write_data(url, :sys_regions, state.id) | |
| 414 | + write_data(url, :sys_regions, state.id, cfg.config.lang) | |
| 400 | 415 | bcast(state.id, :system_information, 10, @tfh) |
| 401 | 416 | |
| 402 | 417 | "system_alerts" -> |
| 403 | − write_data(url, :sys_alerts, state.id) | |
| 418 | + write_data(url, :sys_alerts, state.id, cfg.config.lang) | |
| 404 | 419 | bcast(state.id, :system_alerts, 11, @tfh) |
| 405 | 420 | |
| 406 | 421 | "vehicle_types" -> |
| 407 | − write_data(url, :vehicle_types, state.id) | |
| 422 | + write_data(url, :vehicle_types, state.id, cfg.config.lang) | |
| 408 | 423 | bcast(state.id, :vehicle_types, 12, @tfh) |
| 409 | 424 | |
| 410 | 425 | "system_pricing_plans" -> |
| 411 | − write_data(url, :pricing_plans, state.id) | |
| 426 | + write_data(url, :pricing_plans, state.id, cfg.config.lang) | |
| 412 | 427 | bcast(state.id, :pricing_plans, 13, @tfh) |
| 413 | 428 | |
| 414 | 429 | "geofencing_zones" -> |
| 415 | − write_data(url, :geofencing_zones, state.id) | |
| 430 | + write_data(url, :geofencing_zones, state.id, cfg.config.lang) | |
| 416 | 431 | bcast(state.id, :geofencing_zones, 14, @tfh) |
| 417 | 432 | |
| 418 | 433 | otherwise -> |
| 420 | 435 | end |
| 421 | 436 | end) |
| 422 | 437 | |
| 423 | − {:noreply, state} | |
| 424 | − else | |
| 425 | − bcast( | |
| 426 | − state.id, | |
| 427 | − :language_error, | |
| 428 | − "Invalid Language Selected, available #{json_body["data"] |> Map.keys() |> Enum.join(", ")}, selected: #{cfg.config.lang}" | |
| 429 | − ) | |
| 438 | + {:noreply, state} | |
| 439 | + | |
| 440 | + {:error, languages} -> | |
| 441 | + bcast( | |
| 442 | + state.id, | |
| 443 | + :language_error, | |
| 444 | + "Invalid Language Selected, available #{Enum.join(languages, ", ")}, selected: #{cfg.config.lang}" | |
| 445 | + ) | |
| 430 | 446 | |
| 431 | − {:noreply, state} | |
| 447 | + {:noreply, state} | |
| 432 | 448 | end |
| 433 | 449 | |
| 434 | 450 | {:error, info} -> |
| 463 | 479 | |> Enum.map(fn %{"name" => name, "url" => url} -> |
| 464 | 480 | case name do |
| 465 | 481 | "station_information" -> |
| 466 | − write_data(url, :stat_info, state.id) | |
| 482 | + write_data(url, :stat_info, state.id, cfg.config.lang) | |
| 467 | 483 | bcast(state.id, :system_information, 3, 5) |
| 468 | 484 | |
| 469 | 485 | "ebikes_at_stations" -> |
| 470 | − write_data(url, :ebikes_at_stations, state.id) | |
| 486 | + write_data(url, :ebikes_at_stations, state.id, cfg.config.lang) | |
| 471 | 487 | bcast(state.id, :system_information, 4, 5) |
| 472 | 488 | |
| 473 | 489 | "free_bike_status" -> |
| 474 | − write_data(url, :free_bike, state.id) | |
| 490 | + write_data(url, :free_bike, state.id, cfg.config.lang) | |
| 475 | 491 | bcast(state.id, :system_information, 5, 5) |
| 476 | 492 | |
| 477 | 493 | _otherwise -> |
added
apps/room_gbfs/lib/room_gbfs/v3.ex
+158
−0
| 1 | +defmodule RoomGbfs.V3 do | |
| 2 | + @moduledoc """ | |
| 3 | + Reading a GBFS v3 feed as though it were v2. | |
| 4 | + | |
| 5 | + v3 renamed things rather than adding them, which is the awkward kind of | |
| 6 | + change: a v3 feed parses without complaint against a v2 reader and comes out | |
| 7 | + empty, because `num_vehicles_available` is simply not a field anyone asked | |
| 8 | + for and Ecto's `cast/3` drops what it was not told about. No error, no log, a | |
| 9 | + station with no bikes in it. | |
| 10 | + | |
| 11 | + So this translates a v3 record into the v2 shape the schemas, condensers and | |
| 12 | + clients already speak, rather than teaching every one of them both dialects. | |
| 13 | + | |
| 14 | + **Every function here is idempotent and version-blind.** A v2 record has | |
| 15 | + `num_bikes_available` and no `num_vehicles_available`, so the rename is a | |
| 16 | + no-op; a v3 record has the reverse. That is deliberate: it means nothing | |
| 17 | + upstream has to know which version it is holding, and there is no version | |
| 18 | + flag to thread through -- or to get wrong for the one operator who publishes | |
| 19 | + a v3 document with a v2 version string in it. | |
| 20 | + | |
| 21 | + What is *not* handled, and cannot be here: v3 has no `num_ebikes_available`. | |
| 22 | + The electric count is derivable, but only by joining `vehicle_types_available` | |
| 23 | + against `vehicle_types.json`, which is a different feed fetched separately | |
| 24 | + and possibly later. A v3 station reports its total correctly and its electric | |
| 25 | + count as nothing. | |
| 26 | + """ | |
| 27 | + | |
| 28 | + @doc """ | |
| 29 | + The feed list out of a discovery document, whichever version wrote it. | |
| 30 | + | |
| 31 | + Told apart by shape rather than by the `version` field. v2 keys `data` by | |
| 32 | + language -- `data.en.feeds` -- and v3 dropped that, so `data.feeds` is a list | |
| 33 | + in v3 and cannot be one in v2. Shape is the thing that actually has to be | |
| 34 | + true for the parse to work; the version string is a claim about it. | |
| 35 | + | |
| 36 | + Returns `{:ok, version, feeds}`, or `{:error, languages}` for a v2 document | |
| 37 | + that does not carry the language the source is configured for -- which is the | |
| 38 | + one case worth reporting to the operator, since it is a setting they chose. | |
| 39 | + """ | |
| 40 | + def feeds(%{"data" => %{"feeds" => feeds}}, _lang) when is_list(feeds), do: {:ok, :v3, feeds} | |
| 41 | + | |
| 42 | + def feeds(%{"data" => data}, lang) when is_map(data) do | |
| 43 | + case Map.get(data, lang) do | |
| 44 | + %{"feeds" => feeds} when is_list(feeds) -> {:ok, :v2, feeds} | |
| 45 | + _ -> {:error, Map.keys(data)} | |
| 46 | + end | |
| 47 | + end | |
| 48 | + | |
| 49 | + def feeds(_body, _lang), do: {:error, []} | |
| 50 | + | |
| 51 | + @doc """ | |
| 52 | + The v2 name for a feed. | |
| 53 | + | |
| 54 | + v3 renamed `free_bike_status` to `vehicle_status` -- the only rename among | |
| 55 | + the feeds this reads. `system_hours` and `system_calendar` were removed | |
| 56 | + outright rather than renamed, so they simply stop arriving. | |
| 57 | + """ | |
| 58 | + def feed_name("vehicle_status"), do: "free_bike_status" | |
| 59 | + def feed_name(name), do: name | |
| 60 | + | |
| 61 | + @doc """ | |
| 62 | + A station's status, in v2 terms. | |
| 63 | + | |
| 64 | + v3 counts vehicles where v2 counted bikes, which is the honest name for a | |
| 65 | + system that rents scooters -- but it is a rename, and a reader looking for | |
| 66 | + the old key finds nothing rather than failing. | |
| 67 | + """ | |
| 68 | + def station_status(row) do | |
| 69 | + row | |
| 70 | + |> rename(:num_vehicles_available, :num_bikes_available) | |
| 71 | + |> rename(:num_vehicles_disabled, :num_bikes_disabled) | |
| 72 | + end | |
| 73 | + | |
| 74 | + @doc """ | |
| 75 | + A loose vehicle, in v2 terms. | |
| 76 | + | |
| 77 | + `vehicle_status.json` in v3, `free_bike_status.json` in v2, and the id | |
| 78 | + changed name with the file. The id matters more than most: it is half the | |
| 79 | + conflict target these rows are written on, so a null one collapses every | |
| 80 | + bike in the feed onto a single row. | |
| 81 | + """ | |
| 82 | + def vehicle_status(row), do: rename(row, :vehicle_id, :bike_id) | |
| 83 | + | |
| 84 | + @doc """ | |
| 85 | + A station's description, in v2 terms. | |
| 86 | + | |
| 87 | + v3 made `name` a list of `{text, language}` rather than a string, so that a | |
| 88 | + bilingual city can publish both. The schema holds one string, so one is | |
| 89 | + chosen -- the configured language where the feed offers it. | |
| 90 | + """ | |
| 91 | + def station_info(row, lang) do | |
| 92 | + row | |
| 93 | + |> localize(:name, lang) | |
| 94 | + |> localize(:short_name, lang) | |
| 95 | + end | |
| 96 | + | |
| 97 | + @doc "A vehicle type, in v2 terms." | |
| 98 | + def vehicle_types(row, lang), do: localize(row, :name, lang) | |
| 99 | + | |
| 100 | + @doc """ | |
| 101 | + A system's own description, in v2 terms. | |
| 102 | + | |
| 103 | + Same localisation as everywhere else, plus `languages`: v3 made it a list, | |
| 104 | + on the same reasoning that made the names lists. | |
| 105 | + """ | |
| 106 | + def system_information(row, lang) do | |
| 107 | + row | |
| 108 | + |> localize(:name, lang) | |
| 109 | + |> localize(:short_name, lang) | |
| 110 | + |> localize(:operator, lang) | |
| 111 | + |> first_language() | |
| 112 | + end | |
| 113 | + | |
| 114 | + @doc """ | |
| 115 | + One string out of a v3 localised value. | |
| 116 | + | |
| 117 | + `[%{text: "Bicing", language: "ca"}]` in v3, `"Bicing"` in v2, and a bare | |
| 118 | + string is returned untouched -- which is what makes this safe to run over a | |
| 119 | + v2 feed. | |
| 120 | + | |
| 121 | + Prefers the language asked for, then falls back to the first the feed gives | |
| 122 | + rather than to nothing: a name in the wrong language is still a name, and an | |
| 123 | + operator who publishes only Catalan should not read as an unnamed station. | |
| 124 | + """ | |
| 125 | + def text(value, lang \\ nil) | |
| 126 | + | |
| 127 | + def text([%{} | _] = localized, lang) do | |
| 128 | + match = | |
| 129 | + Enum.find(localized, fn entry -> | |
| 130 | + lang != nil and to_string(Map.get(entry, :language)) == to_string(lang) | |
| 131 | + end) | |
| 132 | + | |
| 133 | + entry = match || List.first(localized) | |
| 134 | + Map.get(entry, :text) | |
| 135 | + end | |
| 136 | + | |
| 137 | + def text(value, _lang), do: value | |
| 138 | + | |
| 139 | + defp localize(row, key, lang) do | |
| 140 | + case Map.fetch(row, key) do | |
| 141 | + {:ok, value} -> Map.put(row, key, text(value, lang)) | |
| 142 | + :error -> row | |
| 143 | + end | |
| 144 | + end | |
| 145 | + | |
| 146 | + # v3 publishes `languages: ["ca", "es"]`; the schema holds one. | |
| 147 | + defp first_language(%{languages: [first | _]} = row), do: Map.put(row, :language, first) | |
| 148 | + defp first_language(row), do: row | |
| 149 | + | |
| 150 | + # Moves a key only when the old name is absent, so running this over a v2 | |
| 151 | + # record that already has the destination cannot overwrite it. | |
| 152 | + defp rename(row, from, to) do | |
| 153 | + case Map.fetch(row, from) do | |
| 154 | + {:ok, value} -> row |> Map.delete(from) |> Map.put_new(to, value) | |
| 155 | + :error -> row | |
| 156 | + end | |
| 157 | + end | |
| 158 | +end |
added
apps/room_gbfs/test/room_gbfs/v3_test.exs
+126
−0
| 1 | +defmodule RoomGbfs.V3Test do | |
| 2 | + use ExUnit.Case, async: true | |
| 3 | + | |
| 4 | + alias RoomGbfs.V3 | |
| 5 | + | |
| 6 | + describe "discovery" do | |
| 7 | + test "v2 keys its feeds by language" do | |
| 8 | + body = %{ | |
| 9 | + "data" => %{ | |
| 10 | + "en" => %{"feeds" => [%{"name" => "station_status", "url" => "http://x/ss"}]}, | |
| 11 | + "fr" => %{"feeds" => []} | |
| 12 | + } | |
| 13 | + } | |
| 14 | + | |
| 15 | + assert {:ok, :v2, [%{"name" => "station_status"}]} = V3.feeds(body, "en") | |
| 16 | + end | |
| 17 | + | |
| 18 | + test "v3 does not, and is told apart by that" do | |
| 19 | + # Shape rather than the version string: this is what actually has to be | |
| 20 | + # true for the parse to work. | |
| 21 | + body = %{ | |
| 22 | + "version" => "3.0", | |
| 23 | + "data" => %{"feeds" => [%{"name" => "vehicle_status", "url" => "http://x/vs"}]} | |
| 24 | + } | |
| 25 | + | |
| 26 | + assert {:ok, :v3, [%{"name" => "vehicle_status"}]} = V3.feeds(body, "en") | |
| 27 | + end | |
| 28 | + | |
| 29 | + test "a v2 feed missing the configured language reports what it does have" do | |
| 30 | + body = %{"data" => %{"ca" => %{"feeds" => []}, "es" => %{"feeds" => []}}} | |
| 31 | + | |
| 32 | + assert {:error, languages} = V3.feeds(body, "en") | |
| 33 | + assert Enum.sort(languages) == ["ca", "es"] | |
| 34 | + end | |
| 35 | + | |
| 36 | + test "a body that is not a discovery document does not raise" do | |
| 37 | + assert {:error, []} = V3.feeds(%{}, "en") | |
| 38 | + assert {:error, []} = V3.feeds(%{"data" => nil}, "en") | |
| 39 | + end | |
| 40 | + end | |
| 41 | + | |
| 42 | + describe "feed names" do | |
| 43 | + test "v3's vehicle_status is v2's free_bike_status" do | |
| 44 | + assert V3.feed_name("vehicle_status") == "free_bike_status" | |
| 45 | + end | |
| 46 | + | |
| 47 | + test "everything else keeps its name" do | |
| 48 | + assert V3.feed_name("station_status") == "station_status" | |
| 49 | + end | |
| 50 | + end | |
| 51 | + | |
| 52 | + describe "station status" do | |
| 53 | + test "v3 counts vehicles where v2 counted bikes" do | |
| 54 | + row = %{station_id: "1", num_vehicles_available: 7, num_vehicles_disabled: 2} | |
| 55 | + | |
| 56 | + assert %{num_bikes_available: 7, num_bikes_disabled: 2} = V3.station_status(row) | |
| 57 | + end | |
| 58 | + | |
| 59 | + test "a v2 row passes through untouched" do | |
| 60 | + row = %{station_id: "1", num_bikes_available: 4, num_bikes_disabled: 0} | |
| 61 | + | |
| 62 | + assert V3.station_status(row) == row | |
| 63 | + end | |
| 64 | + | |
| 65 | + test "the v3 key does not survive the rename" do | |
| 66 | + # It would be cast away anyway, but leaving it invites a reader to think | |
| 67 | + # both names are live. | |
| 68 | + refute Map.has_key?(V3.station_status(%{num_vehicles_available: 3}), :num_vehicles_available) | |
| 69 | + end | |
| 70 | + end | |
| 71 | + | |
| 72 | + describe "loose vehicles" do | |
| 73 | + test "v3's vehicle_id is v2's bike_id" do | |
| 74 | + assert %{bike_id: "b1"} = V3.vehicle_status(%{vehicle_id: "b1", lat: 1.0, lon: 2.0}) | |
| 75 | + end | |
| 76 | + | |
| 77 | + test "a v2 row passes through untouched" do | |
| 78 | + row = %{bike_id: "b1", lat: 1.0, lon: 2.0} | |
| 79 | + assert V3.vehicle_status(row) == row | |
| 80 | + end | |
| 81 | + end | |
| 82 | + | |
| 83 | + describe "localised text" do | |
| 84 | + test "the configured language wins" do | |
| 85 | + value = [%{text: "Bicing", language: "ca"}, %{text: "Bicing ES", language: "es"}] | |
| 86 | + | |
| 87 | + assert V3.text(value, "es") == "Bicing ES" | |
| 88 | + end | |
| 89 | + | |
| 90 | + test "a feed that does not carry that language still gives a name" do | |
| 91 | + # Wrong language beats no name at all. | |
| 92 | + value = [%{text: "Bicing", language: "ca"}] | |
| 93 | + | |
| 94 | + assert V3.text(value, "en") == "Bicing" | |
| 95 | + end | |
| 96 | + | |
| 97 | + test "a v2 string is returned as it is" do | |
| 98 | + assert V3.text("Packard Ave", "en") == "Packard Ave" | |
| 99 | + assert V3.text(nil, "en") == nil | |
| 100 | + end | |
| 101 | + | |
| 102 | + test "a station's name is localised in place" do | |
| 103 | + row = %{station_id: "1", name: [%{text: "Plaça Espanya", language: "ca"}]} | |
| 104 | + | |
| 105 | + assert %{name: "Plaça Espanya", station_id: "1"} = V3.station_info(row, "ca") | |
| 106 | + end | |
| 107 | + | |
| 108 | + test "a station with no short_name does not gain one" do | |
| 109 | + refute Map.has_key?(V3.station_info(%{name: "X"}, "en"), :short_name) | |
| 110 | + end | |
| 111 | + end | |
| 112 | + | |
| 113 | + describe "system information" do | |
| 114 | + test "v3's language list becomes the one language the schema holds" do | |
| 115 | + row = %{system_id: "bicing", languages: ["ca", "es"], name: [%{text: "Bicing", language: "ca"}]} | |
| 116 | + | |
| 117 | + assert %{language: "ca", name: "Bicing"} = V3.system_information(row, "ca") | |
| 118 | + end | |
| 119 | + | |
| 120 | + test "a v2 system is unchanged" do | |
| 121 | + row = %{system_id: "bluebikes", language: "en", name: "Bluebikes", operator: "Lyft"} | |
| 122 | + | |
| 123 | + assert V3.system_information(row, "en") == row | |
| 124 | + end | |
| 125 | + end | |
| 126 | +end |
Parents: 59d6dae