diff --git a/README.md b/README.md index 1d97adf0..5d720058 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Here's a general overview of the realtime_signs application which should be help ## Development +* If it's your first time using asdf, run `asdf plugin add erlang && asdf plugin add elixir`. * Run `asdf install` from the repository root. * Note: If running macOS Sonoma on an Apple Silicon (ARM) machine, use `KERL_CONFIGURE_OPTIONS="--disable-jit" asdf install`[^1] diff --git a/lib/engine/alerts.ex b/lib/engine/alerts.ex index c7fb0ef4..d47b590c 100644 --- a/lib/engine/alerts.ex +++ b/lib/engine/alerts.ex @@ -3,6 +3,7 @@ defmodule Engine.Alerts do use GenServer require Logger alias Engine.Alerts.Fetcher + alias Signs.Utilities.EtsUtils @type ets_tables :: %{ stops_table: :ets.tab(), @@ -101,10 +102,8 @@ defmodule Engine.Alerts do case state.fetcher.get_statuses(state.all_route_ids) do {:ok, %{:stop_statuses => stop_statuses, :route_statuses => route_statuses}} -> - :ets.delete_all_objects(state.tables.stops_table) - :ets.insert(state.tables.stops_table, Enum.into(stop_statuses, [])) - :ets.delete_all_objects(state.tables.routes_table) - :ets.insert(state.tables.routes_table, Enum.into(route_statuses, [])) + EtsUtils.write_ets(state.tables.routes_table, route_statuses, :none) + EtsUtils.write_ets(state.tables.stops_table, stop_statuses, :none) Logger.info( "Engine.Alerts Stop alert statuses: #{inspect(stop_statuses)} Route alert statuses #{inspect(route_statuses)}" diff --git a/lib/engine/locations.ex b/lib/engine/locations.ex index b13e41dd..f2094070 100644 --- a/lib/engine/locations.ex +++ b/lib/engine/locations.ex @@ -7,6 +7,7 @@ defmodule Engine.Locations do use GenServer require Logger + alias Signs.Utilities.EtsUtils @type state :: %{ last_modified_vehicle_positions: String.t() | nil, @@ -67,8 +68,8 @@ defmodule Engine.Locations do case download_data(full_url, state.last_modified_vehicle_positions) do {:ok, body, new_last_modified} -> {locations_by_vehicle, locations_by_stop} = map_locations_data(body) - write_ets(state.vehicle_locations_table, locations_by_vehicle, :none) - write_ets(state.stop_locations_table, locations_by_stop, []) + EtsUtils.write_ets(state.vehicle_locations_table, locations_by_vehicle, :none) + EtsUtils.write_ets(state.stop_locations_table, locations_by_stop, []) new_last_modified :error -> @@ -179,13 +180,4 @@ defmodule Engine.Locations do defp schedule_update(pid) do Process.send_after(pid, :update, 1_000) end - - defp write_ets(table, values, empty_value) do - :ets.tab2list(table) - |> Enum.map(&{elem(&1, 0), empty_value}) - |> Map.new() - |> Map.merge(values) - |> Map.to_list() - |> then(&:ets.insert(table, &1)) - end end diff --git a/lib/engine/predictions.ex b/lib/engine/predictions.ex index 36115b9a..c98aa372 100644 --- a/lib/engine/predictions.ex +++ b/lib/engine/predictions.ex @@ -11,6 +11,7 @@ defmodule Engine.Predictions do use GenServer require Logger + alias Signs.Utilities.EtsUtils defstruct last_modified: nil, trip_updates_table: :trip_updates, @@ -71,12 +72,7 @@ defmodule Engine.Predictions do Predictions.LastTrip.get_recent_departures(parsed_json) |> Engine.LastTrip.update_recent_departures() - :ets.tab2list(state.trip_updates_table) - |> Enum.map(&{elem(&1, 0), []}) - |> Map.new() - |> Map.merge(new_predictions) - |> Map.to_list() - |> then(&:ets.insert(state.trip_updates_table, &1)) + EtsUtils.write_ets(state.trip_updates_table, new_predictions, []) :ets.insert(state.revenue_vehicles_table, {:all, vehicles_running_revenue_trips}) diff --git a/lib/signs/utilities/ets_utils.ex b/lib/signs/utilities/ets_utils.ex new file mode 100644 index 00000000..7cfd8e58 --- /dev/null +++ b/lib/signs/utilities/ets_utils.ex @@ -0,0 +1,26 @@ +defmodule Signs.Utilities.EtsUtils do + @doc """ + Updates an ETS table by resetting all existing keys to a default value + and then merging new values into the table. + Existing values will be overwritten with the empty_value in ETS. + """ + @spec write_ets(:ets.tab(), map(), any()) :: boolean() + def write_ets(table, values, empty_value) when is_map(values) do + :ets.tab2list(table) + |> Enum.map(&{elem(&1, 0), empty_value}) + |> Map.new() + |> Map.merge(values) + |> Map.to_list() + |> then(&:ets.insert(table, &1)) + end + + @spec write_ets(:ets.tab(), [{any(), any()}], any()) :: boolean() + def write_ets(table, values_list, empty_value) when is_list(values_list) do + write_ets(table, Map.new(values_list), empty_value) + end + + @spec write_ets(:ets.tab(), {any(), any()}, any()) :: boolean() + def write_ets(table, single_tuple, empty_value) when is_tuple(single_tuple) do + write_ets(table, Map.new([single_tuple]), empty_value) + end +end diff --git a/test/signs/utilities/ets_utils_test.exs b/test/signs/utilities/ets_utils_test.exs new file mode 100644 index 00000000..08cf1b22 --- /dev/null +++ b/test/signs/utilities/ets_utils_test.exs @@ -0,0 +1,95 @@ +defmodule EtsUtilsTest do + use ExUnit.Case + alias Signs.Utilities.EtsUtils + + # Helper to create a temporary ETS table + defp create_table do + :ets.new(:test_table, [:named_table, :set, :public, {:keypos, 1}]) + end + + setup do + # Create a fresh table before each test + table = create_table() + {:ok, table: table} + end + + test "inserts a single key-value pair into the table", %{table: table} do + # Act + EtsUtils.write_ets(table, %{:key1 => "value1"}, :none) + + # Assert + assert {:key1, "value1"} in :ets.tab2list(table) + end + + test "inserts multiple entries from map", %{table: table} do + # Act + EtsUtils.write_ets(table, %{:key1 => "value1", :key2 => "value2", :key3 => "value3"}, []) + + # Assert + assert {:key1, "value1"} in :ets.tab2list(table) + assert {:key2, "value2"} in :ets.tab2list(table) + assert {:key3, "value3"} in :ets.tab2list(table) + end + + test "replaces existing entries with new ones", %{table: table} do + # Arrange + :ets.insert(table, {:key1, "old_value1"}) + :ets.insert(table, {:key2, "old_value2"}) + + # Act + EtsUtils.write_ets(table, %{:key1 => "new_value1", :key3 => "new_value3"}, []) + + # Assert + assert {:key1, "new_value1"} in :ets.tab2list(table) + assert {:key3, "new_value3"} in :ets.tab2list(table) + refute {:key2, "old_value2"} in :ets.tab2list(table) + assert {:key2, []} in :ets.tab2list(table) + end + + test "updates values of keys not in the new entries to the empty_value", %{ + table: table + } do + # Arrange + :ets.insert(table, {:key1, "old_value1"}) + :ets.insert(table, {:key2, "old_value2"}) + + # Act + EtsUtils.write_ets(table, %{:key1 => "new_value1"}, :none) + + # Assert + assert {:key1, "new_value1"} in :ets.tab2list(table) + assert {:key2, :none} in :ets.tab2list(table) + end + + test " inserts a single entry when new entry is a tuple", %{table: table} do + # Act + EtsUtils.write_ets(table, {:key1, "value1"}, :none) + + # Assert + assert {:key1, "value1"} in :ets.tab2list(table) + end + + test "inserts multiple entries when new entries is a list of tuples", %{table: table} do + # Act + EtsUtils.write_ets(table, [{:key1, "value1"}, {:key2, "value2"}, {:key3, "value3"}], :none) + + # Assert + assert {:key1, "value1"} in :ets.tab2list(table) + assert {:key2, "value2"} in :ets.tab2list(table) + assert {:key3, "value3"} in :ets.tab2list(table) + end + + test "overwrites old entries when new value is empty list", %{table: table} do + # Arrange + :ets.insert(table, {:key1, "old_value1"}) + :ets.insert(table, {:key2, "old_value2"}) + + # Act + output = EtsUtils.write_ets(table, [], :none) + + # Assert + assert {:key1, :none} in :ets.tab2list(table) + assert {:key2, :none} in :ets.tab2list(table) + assert output == true + end +end