From 6d406dbf06d5aead68568b249f9cc30ec4e39736 Mon Sep 17 00:00:00 2001 From: Jon Zimbel Date: Fri, 23 Aug 2024 08:22:07 -0400 Subject: [PATCH] test: Property tests for TripUpdate and VehiclePosition modules --- lib/transit_data/glides_report/trip_update.ex | 46 ++- mix.exs | 1 + mix.lock | 1 + test/support/generators/trip_update.ex | 332 ++++++++++++++++++ test/support/generators/vehicle_position.ex | 240 +++++++++++++ .../glides_report/trip_update_test.exs | 140 ++++++++ .../glides_report/vehicle_position_test.exs | 122 +++++++ 7 files changed, 873 insertions(+), 9 deletions(-) create mode 100644 test/support/generators/trip_update.ex create mode 100644 test/support/generators/vehicle_position.ex create mode 100644 test/transit_data/glides_report/trip_update_test.exs create mode 100644 test/transit_data/glides_report/vehicle_position_test.exs diff --git a/lib/transit_data/glides_report/trip_update.ex b/lib/transit_data/glides_report/trip_update.ex index cce0bde..f73d3f3 100644 --- a/lib/transit_data/glides_report/trip_update.ex +++ b/lib/transit_data/glides_report/trip_update.ex @@ -5,6 +5,22 @@ defmodule TransitData.GlidesReport.TripUpdate do alias TransitData.GlidesReport + @doc """ + Cleans up a TripUpdate parsed from raw JSON. (With keys not yet converted to + atoms) + + Returns nil if there is no data relevant to Glides terminals in the TripUpdate. + + - Canceled TripUpdates are discarded. + - Nonrevenue TripUpdates are discarded. + - `.trip_update.timestamp` is replaced with the given `header_timestamp` if + missing or nil + - `.trip_update.stop_time_update` list is filtered to non-skipped entries with + defined departure times, at Glides terminal stops. If the filtered list is + empty, the entire TripUpdate is discarded. + - All unused fields are removed. + """ + @spec clean_up(map, integer) :: map | nil def clean_up(tr_upd, header_timestamp) def clean_up(%{"trip_update" => %{"trip" => %{"schedule_relationship" => "CANCELED"}}}, _) do @@ -46,14 +62,16 @@ defmodule TransitData.GlidesReport.TripUpdate do end defp clean_up_stop_times(stop_times) do + glides_terminals = GlidesReport.Terminals.all_first_stops() + stop_times - # Ignore stop times that aren't relevant to Glides terminals. - |> Stream.reject(&(&1["stop_id"] not in GlidesReport.Terminals.all_stops())) - # Select stop times that have departure times and aren't skipped. |> Stream.filter(fn stop_time -> - has_departure_time = not is_nil(stop_time["departure"]["time"]) - is_skipped = stop_time["schedule_relationship"] == "SKIPPED" - has_departure_time and not is_skipped + cond do + is_nil(stop_time["departure"]["time"]) -> false + stop_time["schedule_relationship"] == "SKIPPED" -> false + stop_time["stop_id"] not in glides_terminals -> false + :else -> true + end end) # Prune all but the relevant fields. |> Enum.map(fn @@ -81,8 +99,18 @@ defmodule TransitData.GlidesReport.TripUpdate do def filter_by_advance_notice(tr_upd, min_advance_notice_sec) do time_of_creation = tr_upd.trip_update.timestamp - update_in(tr_upd.trip_update.stop_time_update, fn stop_times -> - Enum.filter(stop_times, &(&1.departure.time - time_of_creation >= min_advance_notice_sec)) - end) + filtered_stop_times = + Enum.filter( + tr_upd.trip_update.stop_time_update, + &(&1.departure.time - time_of_creation >= min_advance_notice_sec) + ) + + case filtered_stop_times do + [] -> + nil + + filtered_stop_times -> + put_in(tr_upd.trip_update.stop_time_update, filtered_stop_times) + end end end diff --git a/mix.exs b/mix.exs index a508f94..88ee25f 100644 --- a/mix.exs +++ b/mix.exs @@ -45,6 +45,7 @@ defmodule TransitData.MixProject do {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:lcov_ex, "~> 0.3", only: [:dev, :test], runtime: false}, {:mox, "~> 1.0", only: :test}, + {:stream_data, "~> 1.0", only: :test}, # Provided by Mix.install invocation in the notebook. # We only need to directly get this dep when running tests. {:tz, "~> 0.26.5", only: [:test]} diff --git a/mix.lock b/mix.lock index 6274121..dc7a1d3 100644 --- a/mix.lock +++ b/mix.lock @@ -22,6 +22,7 @@ "nimble_ownership": {:hex, :nimble_ownership, "1.0.0", "3f87744d42c21b2042a0aa1d48c83c77e6dd9dd357e425a038dd4b49ba8b79a1", [:mix], [], "hexpm", "7c16cc74f4e952464220a73055b557a273e8b1b7ace8489ec9d86e9ad56cb2cc"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "stream_data": {:hex, :stream_data, "1.1.1", "fd515ca95619cca83ba08b20f5e814aaf1e5ebff114659dc9731f966c9226246", [:mix], [], "hexpm", "45d0cd46bd06738463fd53f22b70042dbb58c384bb99ef4e7576e7bb7d3b8c8c"}, "stream_gzip": {:hex, :stream_gzip, "0.4.2", "838044dd31dcb15d0a29e1c80b82835c0e7fd5ab81baac328c84e0266c35b9d0", [:mix], [], "hexpm", "3657a5d68c6700b24160b793d74e039074b799abd30ba0727a0c2084a0d3e100"}, "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, diff --git a/test/support/generators/trip_update.ex b/test/support/generators/trip_update.ex new file mode 100644 index 0000000..c30c9d4 --- /dev/null +++ b/test/support/generators/trip_update.ex @@ -0,0 +1,332 @@ +defmodule TransitData.GlidesReport.Generators.TripUpdate do + @moduledoc """ + Generates trip updates, particularly for testing the GlidesReport modules. + """ + + import StreamData + import ExUnitProperties, only: [gen: 2] + + # (Ignore first item, it keeps the formatter from moving the first comment) + @type trip_update_gen_opt :: + :___ + # Force all trip updates to be canceled, or not canceled. + | {:canceled?, boolean} + # Force all trip updates to be revenue, or nonrevenue. + | {:revenue?, boolean} + # Force all trip updates to have timestamps defined at .trip_update.timestamp, or not. + | {:define_timestamp, boolean} + # Force the first element of stop_time_update to be at a Glides terminal, or not. + | {:force_glides_terminal, boolean} + # Force the first / all stop time(s) to have defined departure time, or not. + | {:force_departure_time, {:first, boolean} | {:all, boolean}} + # Force departure times to be within a range from the main timestamp + | {:force_notice_range, Range.t()} + + @doc """ + Returns a StreamData generator that yields valid trip updates. + + Use opts to narrow the output. + """ + @spec valid_trip_update_generator([trip_update_gen_opt]) :: StreamData.t(map()) + def valid_trip_update_generator(opts \\ []) do + canceled_gen = + case opts[:canceled?] do + nil -> boolean() + canceled? -> constant(canceled?) + end + + make_revenue_gen = fn canceled? -> + case opts[:revenue?] do + nil -> map(boolean(), &if(canceled?, do: &1, else: true)) + revenue? -> constant(revenue?) + end + end + + gen all( + trip_id <- map(positive_integer(), &Integer.to_string/1), + canceled? <- canceled_gen, + # Revenue can be false only if canceled is true. + # (There are some exceptions to this, but let's not complicate things even more.) + revenue? <- make_revenue_gen.(canceled?), + timestamp <- timestamp_generator_(canceled?, revenue?, opts[:define_timestamp]), + stop_time_update <- + stop_time_update_generator_( + canceled?, + revenue?, + opts[:force_glides_terminal], + opts[:force_departure_time], + opts[:force_notice_range] + ), + vehicle <- vehicle_generator_(canceled?, revenue?), + trip <- trip_generator(trip_id, canceled?, revenue?) + ) do + trip_update = + %{ + "trip" => trip, + "timestamp" => timestamp, + "stop_time_update" => stop_time_update, + "vehicle" => vehicle + } + |> Map.reject(&match?({_k, nil}, &1)) + + %{ + "id" => trip_id, + "trip_update" => trip_update + } + end + end + + def canceled_trip_update_generator do + valid_trip_update_generator(canceled?: true) + end + + def nonrevenue_trip_update_generator do + valid_trip_update_generator(revenue?: false) + end + + def missing_timestamp_trip_update_generator do + valid_trip_update_generator( + define_timestamp: false, + revenue?: true, + canceled?: false, + force_glides_terminal: true, + force_departure_time: {:first, true} + ) + end + + def relevant_trip_update_generator do + valid_trip_update_generator( + revenue?: true, + canceled?: false, + force_glides_terminal: true, + force_departure_time: {:first, true} + ) + end + + def short_notice_trip_update_generator(min_notice, max_notice) do + valid_trip_update_generator( + define_timestamp: false, + revenue?: true, + canceled?: false, + force_glides_terminal: true, + force_departure_time: {:first, true}, + force_notice_range: min_notice..max_notice//1 + ) + end + + # An arbitrary unix timestamp to use in tests. + @header_timestamp 1_698_235_200 + def header_timestamp, do: @header_timestamp + + # Generator fns with names ending in `_` may generate nil. + + defp vehicle_generator_(true, false), do: constant(nil) + + defp vehicle_generator_(_, _) do + gen all( + keys <- member_of([["id"], ["id", "label"], []]), + base <- fixed_map(%{"id" => string(:ascii), "label" => string(:ascii)}) + ) do + vehicle = Map.take(base, keys) + if vehicle == %{}, do: nil, else: vehicle + end + end + + defp stop_time_update_generator_( + canceled?, + revenue?, + force_glides_terminal, + force_departure_time, + force_notice_range + ) + + defp stop_time_update_generator_(true, false, _, _, _), do: constant(nil) + + defp stop_time_update_generator_( + canceled?, + _, + force_glides_terminal, + force_departure_time, + force_notice_range + ) do + first_stop_id_generator = + case force_glides_terminal do + nil -> &maybe_terminal_stop_id_generator/0 + true -> &terminal_stop_id_generator/0 + false -> &non_terminal_stop_id_generator/0 + end + + {force_first_departure_time, force_all_departure_times} = + case force_departure_time do + nil -> {nil, nil} + {:first, force?} -> {force?, nil} + {:all, force?} -> {force?, force?} + end + + first_stop_time = + stop_time_generator( + canceled?, + first_stop_id_generator, + force_first_departure_time, + force_notice_range + ) + |> Enum.at(1) + + # length -> start with a stop_id -> rest can be whatever + stop_time_generator( + canceled?, + &non_terminal_stop_id_generator/0, + force_all_departure_times, + force_notice_range + ) + |> list_of(min_length: 4, max_length: 19) + |> map(fn l -> + [first_stop_time | l] + |> Enum.with_index(fn stop_time, i -> + Map.put(stop_time, "stop_sequence", i + 1) + end) + end) + end + + # Generates stop_time objects sans stop_sequence field, + # due to a limitation of generators (they're stateless). + # stop_sequence is added later on, once the full stop_time_update + # list has been generated. + defp stop_time_generator( + skipped?, + get_stop_id_generator, + force_departure_time, + force_notice_range + ) + + defp stop_time_generator(true, _, _, _) do + non_terminal_stop_id_generator() + |> map(&%{"stop_id" => &1, "schedule_relationship" => "SKIPPED"}) + end + + defp stop_time_generator(false, get_stop_id_generator, force_departure_time, force_notice_range) do + gen all( + stop_id <- get_stop_id_generator.(), + {arrival_t, departure_t} <- timespan_generator(force_notice_range), + departure <- arrival_departure_generator(departure_t, force_departure_time), + arrival <- arrival_departure_generator(arrival_t, is_nil(departure)), + boarding_status <- boarding_status_generator() + ) do + %{ + "stop_id" => stop_id, + "arrival" => arrival, + "departure" => departure, + "boarding_status" => boarding_status + } + |> Map.reject(&match?({_k, nil}, &1)) + end + end + + defp timespan_generator(nil) do + gen all( + arrival_t <- integer((header_timestamp() + 30)..(header_timestamp() + 3600)//1), + departure_t <- integer((arrival_t + 1)..(arrival_t + 1800)//1) + ) do + {arrival_t, departure_t} + end + end + + defp timespan_generator(min_notice..max_notice//1) do + gen all( + arrival_t <- constant(header_timestamp() + min_notice - 1), + departure_t <- + integer((header_timestamp() + min_notice)..(header_timestamp() + max_notice)//1) + ) do + {arrival_t, departure_t} + end + end + + defp boarding_status_generator do + member_of([nil, "Now boarding", "On time", "Stopped 3 stops away"]) + end + + defp arrival_departure_generator(t, force_define_time) do + key_combos = + case force_define_time do + nil -> [[], ["time"], ["time", "uncertainty"]] + true -> [["time"], ["time", "uncertainty"]] + false -> [[]] + end + + gen all( + keys <- member_of(key_combos), + base <- fixed_map(%{"time" => constant(t), "uncertainty" => positive_integer()}) + ) do + arr_or_dep = Map.take(base, keys) + if arr_or_dep == %{}, do: nil, else: arr_or_dep + end + end + + # May produce Glides terminal stop IDs. + defp maybe_terminal_stop_id_generator do + one_of([ + terminal_stop_id_generator(), + non_terminal_stop_id_generator() + ]) + end + + defp terminal_stop_id_generator do + member_of(TransitData.GlidesReport.Terminals.all_first_stops()) + end + + # Never produces Glides terminal stop IDs. + defp non_terminal_stop_id_generator do + gen all( + i <- integer(70_000..79_999//1), + id = Integer.to_string(i), + id not in TransitData.GlidesReport.Terminals.all_first_stops() + ) do + id + end + end + + defp trip_generator(trip_id, canceled?, revenue?) do + gen all( + direction_id <- member_of([0, 1]), + last_trip? <- boolean(), + route_id <- member_of(["Mattapan" | Enum.map(~w[B C D E], &("Green-" <> &1))]), + start_date <- map(positive_integer(), &Integer.to_string/1), + start_time <- time_generator(), + route_pattern_id <- string(:ascii) + ) do + trip = %{ + "direction_id" => direction_id, + "last_trip" => last_trip?, + "revenue" => revenue?, + "route_id" => route_id, + "trip_id" => trip_id, + "start_date" => start_date, + "start_time" => start_time, + "route_pattern_id" => route_pattern_id + } + + if canceled?, do: Map.put(trip, "schedule_relationship", "CANCELED"), else: trip + end + end + + defp time_generator do + tuple({integer(0..23), integer(0..59), integer(0..59)}) + |> map(fn parts -> + parts + |> Tuple.to_list() + |> Enum.map_join(":", &(&1 |> Integer.to_string() |> String.pad_leading(2, "0"))) + end) + end + + defp timestamp_generator_(canceled?, revenue?, force_define) + + defp timestamp_generator_(_, _, true), do: positive_integer() + + defp timestamp_generator_(_, _, false), do: constant(nil) + + defp timestamp_generator_(true, false, _), do: constant(nil) + + defp timestamp_generator_(_, _, _) do + one_of([positive_integer(), constant(nil)]) + end +end diff --git a/test/support/generators/vehicle_position.ex b/test/support/generators/vehicle_position.ex new file mode 100644 index 0000000..db3f7a1 --- /dev/null +++ b/test/support/generators/vehicle_position.ex @@ -0,0 +1,240 @@ +defmodule TransitData.GlidesReport.Generators.VehiclePosition do + @moduledoc """ + Generates vehicle positions, particularly for testing the GlidesReport modules. + """ + + import StreamData + import ExUnitProperties, only: [gen: 2] + + def valid_vehicle_position_generator(opts \\ []) do + current_status_members = + opts[:current_status] || ["INCOMING_AT", "IN_TRANSIT_TO", "STOPPED_AT"] + + stop_id_gen_base = + case opts[:stop_ids] do + nil -> stop_id_generator() + :relevant -> second_stop_id_generator() + :irrelevant -> non_second_stop_id_generator() + end + + stop_id_gen = + case opts[:define_stop_id] do + nil -> one_of([stop_id_gen_base, constant(nil)]) + true -> stop_id_gen_base + false -> constant(nil) + end + + gen all( + vehicle_id <- string(:ascii), + current_status <- member_of(current_status_members), + position <- position_generator(), + timestamp <- positive_integer(), + trip <- trip_generator(opts[:revenue]), + sub_vehicle <- vehicle_generator(vehicle_id), + stop_sequence <- one_of([positive_integer(), constant(nil)]), + carriage_details <- one_of([carriage_details_generator(), constant(nil)]), + occupancy_percentage <- one_of([integer(0..100//1), constant(nil)]), + occupancy_status <- + one_of([ + member_of(["MANY_SEATS_AVAILABLE", "FEW_SEATS_AVAILABLE", "FULL"]), + constant(nil) + ]), + stop_id <- stop_id_gen + ) do + vehicle = + %{ + "current_status" => current_status, + "position" => position, + "timestamp" => timestamp, + "trip" => trip, + "vehicle" => sub_vehicle, + "current_stop_sequence" => stop_sequence, + "multi_carriage_details" => carriage_details, + "occupancy_percentage" => occupancy_percentage, + "occupancy_status" => occupancy_status, + "stop_id" => stop_id + } + |> Map.reject(&match?({_k, nil}, &1)) + + %{"id" => vehicle_id, "vehicle" => vehicle} + end + end + + def stopped_vehicle_position_generator do + valid_vehicle_position_generator( + stop_ids: :relevant, + current_status: ["STOPPED_AT"], + define_stop_id: true, + revenue: true + ) + end + + def missing_stop_id_vehicle_position_generator do + valid_vehicle_position_generator( + stop_ids: :relevant, + current_status: ["INCOMING_AT", "IN_TRANSIT_TO"], + define_stop_id: false, + revenue: true + ) + end + + def nonrevenue_vehicle_position_generator do + valid_vehicle_position_generator( + stop_ids: :relevant, + current_status: ["INCOMING_AT", "IN_TRANSIT_TO"], + define_stop_id: true, + revenue: false + ) + end + + def present_fields_vehicle_position_generator do + valid_vehicle_position_generator( + current_status: ["INCOMING_AT", "IN_TRANSIT_TO"], + define_stop_id: true, + revenue: true + ) + end + + def irrelevant_vehicle_position_generator do + valid_vehicle_position_generator( + stop_ids: :irrelevant, + current_status: ["INCOMING_AT", "IN_TRANSIT_TO"], + define_stop_id: true, + revenue: true + ) + end + + def relevant_vehicle_position_generator do + valid_vehicle_position_generator( + stop_ids: :relevant, + current_status: ["INCOMING_AT", "IN_TRANSIT_TO"], + define_stop_id: true, + revenue: true + ) + end + + defp position_generator do + optional_map( + %{ + "latitude" => float(min: 40.0, max: 42.0), + "longitude" => float(min: -75.0, max: -71.0), + "bearing" => integer(0..360//1), + "speed" => float(min: 0.0, max: 50.0) + }, + ["bearing", "speed"] + ) + end + + defp trip_generator(revenue_opt) do + optional_map( + %{ + "last_trip" => boolean(), + "revenue" => if(is_nil(revenue_opt), do: boolean(), else: constant(revenue_opt)), + "route_id" => string(:alphanumeric), + "schedule_relationship" => member_of(["SCHEDULED", "ADDED", "CANCELED", "UNSCHEDULED"]), + "trip_id" => string(:ascii), + "direction_id" => member_of([0, 1]), + "start_date" => map(positive_integer(), &Integer.to_string/1), + "start_time" => time_generator() + }, + ["direction_id", "start_date", "start_time"] + ) + end + + defp time_generator do + tuple({integer(0..23), integer(0..59), integer(0..59)}) + |> map(fn parts -> + parts + |> Tuple.to_list() + |> Enum.map_join(":", &(&1 |> Integer.to_string() |> String.pad_leading(2, "0"))) + end) + end + + defp vehicle_generator(id) do + fixed_map(%{"id" => constant(id), "label" => string(:ascii)}) + end + + defp carriage_details_generator do + optional_map( + %{ + "label" => map(positive_integer(), &Integer.to_string/1), + "occupancy_status" => + member_of([ + "NO_DATA_AVAILABLE", + "MANY_SEATS_AVAILABLE", + "FEW_SEATS_AVAILABLE", + "STANDING_ROOM_ONLY" + ]), + "orientation" => member_of(["AB", "BA"]), + "occupancy_percentage" => positive_integer() + }, + ["orientation", "occupancy_percentage"] + ) + |> list_of(min_length: 2, max_length: 4) + |> map(fn l -> + Enum.with_index(l, fn detail, i -> + Map.put(detail, "carriage_sequence", i + 1) + end) + end) + end + + defp stop_id_generator do + one_of([ + second_stop_id_generator(), + non_second_stop_id_generator() + ]) + end + + defp second_stop_id_generator do + member_of(TransitData.GlidesReport.Terminals.all_next_stops()) + end + + defp non_second_stop_id_generator do + gen all( + i <- integer(70_000..79_999//1), + id = Integer.to_string(i), + id not in TransitData.GlidesReport.Terminals.all_next_stops() + ) do + id + end + end +end + +# Rough schema of a vehicle position +""" +id: string +vehicle: + current_status: "INCOMING_AT", "IN_TRANSIT_TO", "STOPPED_AT" + position: + latitude: float min_max={39.83250807, 42.765830993652344} + longitude: float min_max={-75.0711326, -70.30698791} + bearing?: 0..360 + speed?: float + timestamp: int + trip: + last_trip: boolean + revenue: boolean + route_id: string + schedule_relationship: "SCHEDULED", "ADDED", "CANCELED", "UNSCHEDULED" + trip_id: string + + direction_id?: boolean + start_date?: string - e.g. "20240820" + start_time?: string - e.g. "19:15:00" + vehicle: + id: string + label: string + + current_stop_sequence?: int + multi_carriage_details?: [ + carriage_sequence: int + label: string - e.g. "1403" + occupancy_status: "NO_DATA_AVAILABLE", "MANY_SEATS_AVAILABLE", "FEW_SEATS_AVAILABLE", "STANDING_ROOM_ONLY" + + orientation?: "AB", "BA" + occupancy_percentage?: int + ] + occupancy_percentage?: int + occupancy_status?: "MANY_SEATS_AVAILABLE", "FEW_SEATS_AVAILABLE", "FULL" + stop_id?: string +""" diff --git a/test/transit_data/glides_report/trip_update_test.exs b/test/transit_data/glides_report/trip_update_test.exs new file mode 100644 index 0000000..6cd2a0c --- /dev/null +++ b/test/transit_data/glides_report/trip_update_test.exs @@ -0,0 +1,140 @@ +defmodule TransitData.GlidesReport.TripUpdateTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias TransitData.GlidesReport.Generators.TripUpdate, as: Gen + + alias TransitData.GlidesReport.TripUpdate + + # Clean up a trip update, using a defined constant as the header timestamp. + defp clean_up(tr_upd) do + TripUpdate.clean_up(tr_upd, Gen.header_timestamp()) + end + + # Convert a raw-map trip update to a cleaned-up version with atom keys. + defp normalize(tr_upd) do + tr_upd + |> clean_up() + |> AtomicMap.convert(underscore: false) + |> TripUpdate.normalize_stop_ids() + end + + describe "clean_up/2" do + property "handles all valid trip updates" do + check all(tr_upd <- Gen.valid_trip_update_generator()) do + _ = clean_up(tr_upd) + assert true + end + end + + property "removes unused fields" do + check all(tr_upd <- Gen.relevant_trip_update_generator()) do + cleaned = clean_up(tr_upd) + + refute is_nil(cleaned) + + assert is_nil(cleaned["trip_update"]["vehicle"]) + assert is_nil(cleaned["trip_update"]["trip"]["direction_id"]) + + Enum.each(cleaned["trip_update"]["stop_time_update"], &assert(is_nil(&1["arrival"]))) + end + end + + property "subs header timestamp in for missing .trip_update.timestamp" do + check all(tr_upd <- Gen.missing_timestamp_trip_update_generator()) do + cleaned = clean_up(tr_upd) + assert cleaned["trip_update"]["timestamp"] == Gen.header_timestamp() + end + end + + property "discards stop_times without defined departure times" do + check all( + tr_upd <- Gen.relevant_trip_update_generator(), + Enum.any?( + tr_upd["trip_update"]["stop_time_update"], + &is_nil(&1["departure"]["time"]) + ) + ) do + cleaned = clean_up(tr_upd) + + Enum.each( + cleaned["trip_update"]["stop_time_update"], + &refute(is_nil(&1["departure"]["time"])) + ) + end + end + + property "discards canceled trips" do + check all(tr_upd <- Gen.canceled_trip_update_generator()) do + cleaned = clean_up(tr_upd) + assert is_nil(cleaned) + end + end + + property "discards nonrevenue trips" do + check all(tr_upd <- Gen.nonrevenue_trip_update_generator()) do + cleaned = clean_up(tr_upd) + assert is_nil(cleaned) + end + end + end + + describe "normalize_stop_ids/1" do + property "converts child stop IDs to parent stop IDs" do + check all(tr_upd <- Gen.relevant_trip_update_generator()) do + normalized = normalize(tr_upd) + Enum.each(normalized.trip_update.stop_time_update, &assert("place-" <> _ = &1.stop_id)) + end + end + end + + describe "filter_stops/2" do + property "removes stops not in the filter list, returns nil if all stops are filtered" do + check all(tr_upd <- Gen.relevant_trip_update_generator()) do + normalized = normalize(tr_upd) + filtered = TripUpdate.filter_stops(normalized, ["place-river"]) + + if "place-river" in get_in(normalized, [ + :trip_update, + :stop_time_update, + Access.all(), + :stop_id + ]) do + Enum.each(filtered.trip_update.stop_time_update, &assert(&1.stop_id == "place-river")) + else + assert is_nil(filtered) + end + end + end + end + + describe "filter_by_advance_notice/2" do + property "returns trip update unchanged if no advance notice filter is set" do + check all(tr_upd <- Gen.relevant_trip_update_generator()) do + normalized = normalize(tr_upd) + filtered = TripUpdate.filter_by_advance_notice(normalized, nil) + + assert normalized == filtered + end + end + + property "removes updates with too-short notice, returns nil if all updates are filtered" do + check all(tr_upd <- Gen.short_notice_trip_update_generator(30, 90)) do + normalized = normalize(tr_upd) + filtered = TripUpdate.filter_by_advance_notice(normalized, 60) + + if Enum.any?( + get_in(normalized, [:trip_update, :stop_time_update, Access.all(), :departure, :time]), + &(&1 >= normalized.trip_update.timestamp + 60) + ) do + Enum.each( + filtered.trip_update.stop_time_update, + &assert(&1.departure.time >= filtered.trip_update.timestamp + 60) + ) + else + assert is_nil(filtered) + end + end + end + end +end diff --git a/test/transit_data/glides_report/vehicle_position_test.exs b/test/transit_data/glides_report/vehicle_position_test.exs new file mode 100644 index 0000000..d9633cb --- /dev/null +++ b/test/transit_data/glides_report/vehicle_position_test.exs @@ -0,0 +1,122 @@ +defmodule TransitData.GlidesReport.VehiclePositionTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias TransitData.GlidesReport.Generators.VehiclePosition, as: Gen + + alias TransitData.GlidesReport.VehiclePosition + + defp normalize(ve_pos) do + ve_pos + |> VehiclePosition.clean_up() + |> AtomicMap.convert(underscore: false) + |> VehiclePosition.normalize_stop_id() + end + + describe "clean_up/1" do + property "handles all vehicle positions" do + check all(ve_pos <- Gen.valid_vehicle_position_generator()) do + _ = VehiclePosition.clean_up(ve_pos) + assert true + end + end + + property "removes unused fields" do + check all(ve_pos <- Gen.relevant_vehicle_position_generator()) do + cleaned = VehiclePosition.clean_up(ve_pos) + assert is_nil(cleaned["vehicle"]["position"]) + assert is_nil(cleaned["vehicle"]["trip"]["last_trip"]) + end + end + + property "discards nonrevenue vehicle positions" do + check all(ve_pos <- Gen.nonrevenue_vehicle_position_generator()) do + cleaned = VehiclePosition.clean_up(ve_pos) + assert is_nil(cleaned) + end + end + + property "discards vehicle positions at stops other than those immediately following Glides terminals" do + check all(ve_pos <- Gen.irrelevant_vehicle_position_generator()) do + cleaned = VehiclePosition.clean_up(ve_pos) + assert is_nil(cleaned) + end + end + + property "discards vehicle positions with missing stop_id" do + check all(ve_pos <- Gen.missing_stop_id_vehicle_position_generator()) do + cleaned = VehiclePosition.clean_up(ve_pos) + assert is_nil(cleaned) + end + end + + property "discards vehicle positions with current_status other than IN_TRANSIT_TO/INCOMING_AT" do + check all(ve_pos <- Gen.stopped_vehicle_position_generator()) do + cleaned = VehiclePosition.clean_up(ve_pos) + assert is_nil(cleaned) + end + end + end + + describe "normalize_stop_ids/1" do + property "converts child stop ID to parent stop ID" do + check all(ve_pos <- Gen.relevant_vehicle_position_generator()) do + normalized = normalize(ve_pos) + assert "place-" <> _ = normalized.vehicle.stop_id + end + end + end + + describe "dedup_statuses/1" do + property "chooses earlier of 2+ vehicle positions for same vehicle+trip+stop, no matter what order they appear in" do + ############################################### + # Create a list of vehicle positions composed # + # of "filler" and a known set of duplicates. # + ############################################### + filler = + Gen.relevant_vehicle_position_generator() + |> Stream.map(&normalize/1) + |> Enum.take(50) + + trip_id = "a unique trip id" + stop_id = "a unique stop id" + vehicle_id = "a unique vehicle id" + + dups = + Gen.relevant_vehicle_position_generator() + |> StreamData.map(fn ve_pos -> + ve_pos + |> normalize() + |> put_in([:vehicle, :trip, :trip_id], trip_id) + |> put_in([:vehicle, :stop_id], stop_id) + |> put_in([:id], vehicle_id) + end) + |> Enum.take(3) + + earliest_time = + dups + |> Enum.map(& &1.vehicle.timestamp) + |> Enum.min() + + ve_posns = dups ++ filler + + ############################################################# + # Repeatedly shuffle the list and check that dedup_statuses # + # chooses the earliest of the dups in all cases. # + ############################################################# + check all(ordering <- StreamData.repeatedly(fn -> Enum.shuffle(ve_posns) end)) do + deduped = VehiclePosition.dedup_statuses(ordering) + + matches = + Enum.filter( + deduped, + &(&1.vehicle.trip.trip_id == trip_id and &1.vehicle.stop_id == stop_id and + &1.id == vehicle_id) + ) + + assert length(matches) == 1 + assert hd(matches).vehicle.timestamp == earliest_time + end + end + end +end