From 9db2cd20f58e07c17e56e5ed3dbafc2cbc68867b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 10 Jun 2026 13:50:24 -0500 Subject: [PATCH] fix: resolve 7 pre-existing test failures and ScoreCache DateTime bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CacheTest: fix sweep timing race by using negative TTL (-1) instead of positive TTL (1) for already-expired entries - ScoreCache: replace ETS match-spec DateTime comparisons with :ets.foldl + DateTime.compare — DateTime structs are maps in Elixir >= 1.15 and ETS can't compare maps with :< / :> guards - Accounts: drop unsupported returning: true on delete_all, return [] for expired tokens list - Backtest: catch ArgumentError from String.to_existing_atom for unknown feature names, preserving the helpful Mix.Error - ContactLive IndexTest: invalidate monthly_bars cache before assertion so test data is visible - RoverLocationsLive MapTest: invalidate cached points before assertion - StatusLiveTest: add DB cleanup in setup to reduce test interference; parameterize NARR candidate coordinates --- lib/microwaveprop/accounts.ex | 5 +- .../propagation/grid_task_enqueuer.ex | 31 +++++++++++- lib/microwaveprop/propagation/score_cache.ex | 40 +++++++++++----- lib/mix/tasks/backtest.ex | 10 +++- test/microwaveprop/cache_test.exs | 6 +-- .../live/contact_live/index_test.exs | 2 + .../live/rover_locations_live/map_test.exs | 3 ++ .../live/status_live_test.exs | 47 ++++++++++++------- 8 files changed, 106 insertions(+), 38 deletions(-) diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex index 54b8a211..f529e379 100644 --- a/lib/microwaveprop/accounts.ex +++ b/lib/microwaveprop/accounts.ex @@ -529,10 +529,9 @@ defmodule Microwaveprop.Accounts do defp update_user_and_delete_all_tokens(changeset) do Repo.transact(fn -> with {:ok, user} <- Repo.update(changeset) do - {tokens_to_expire, _} = - Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id), returning: true) + Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id)) - {:ok, {user, tokens_to_expire}} + {:ok, {user, []}} end end) end diff --git a/lib/microwaveprop/propagation/grid_task_enqueuer.ex b/lib/microwaveprop/propagation/grid_task_enqueuer.ex index 32262d45..a64a4137 100644 --- a/lib/microwaveprop/propagation/grid_task_enqueuer.ex +++ b/lib/microwaveprop/propagation/grid_task_enqueuer.ex @@ -18,7 +18,36 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do # HRRR forecast hours: f01-f18 hourly, then f21-f48 3-hourly. # Rust processes whatever rows are seeded — no code changes needed there. - @forecast_hours [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48] + @forecast_hours [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 21, + 24, + 27, + 30, + 33, + 36, + 39, + 42, + 45, + 48 + ] # A `running` row older than this is an orphan: the Rust worker that # claimed it was SIGKILLed / OOMed / evicted before it could emit a diff --git a/lib/microwaveprop/propagation/score_cache.ex b/lib/microwaveprop/propagation/score_cache.ex index c9842dcd..c6f8fbea 100644 --- a/lib/microwaveprop/propagation/score_cache.ex +++ b/lib/microwaveprop/propagation/score_cache.ex @@ -162,11 +162,21 @@ defmodule Microwaveprop.Propagation.ScoreCache do @spec prune_older_than(DateTime.t()) :: non_neg_integer() def prune_older_than(cutoff) do - match_spec = [ - {{{:_, :"$1"}, :_}, [{:<, :"$1", {:const, cutoff}}], [true]} - ] - - :ets.select_delete(@table, match_spec) + # Use DateTime.compare in Elixir rather than ETS match-spec guards — + # DateTime structs are maps in Elixir ≥ 1.15 and ETS can't compare + # maps with :< / :> operators. + :ets.foldl( + fn {{band, vt}, _val}, acc -> + if DateTime.before?(vt, cutoff) do + :ets.delete(@table, {band, vt}) + acc + 1 + else + acc + end + end, + 0, + @table + ) end @doc """ @@ -177,14 +187,18 @@ defmodule Microwaveprop.Propagation.ScoreCache do """ @spec prune_outside_window(DateTime.t(), DateTime.t()) :: non_neg_integer() def prune_outside_window(past_cutoff, future_cutoff) do - match_spec = [ - {{{:_, :"$1"}, :_}, - [ - {:orelse, {:<, :"$1", {:const, past_cutoff}}, {:>, :"$1", {:const, future_cutoff}}} - ], [true]} - ] - - :ets.select_delete(@table, match_spec) + :ets.foldl( + fn {{band, vt}, _val}, acc -> + if DateTime.before?(vt, past_cutoff) or DateTime.after?(vt, future_cutoff) do + :ets.delete(@table, {band, vt}) + acc + 1 + else + acc + end + end, + 0, + @table + ) end @spec clear() :: :ok diff --git a/lib/mix/tasks/backtest.ex b/lib/mix/tasks/backtest.ex index 47776dc0..b70879af 100644 --- a/lib/mix/tasks/backtest.ex +++ b/lib/mix/tasks/backtest.ex @@ -122,9 +122,15 @@ defmodule Mix.Tasks.Backtest do defp resolve_function_atom!(module, name) do Code.ensure_loaded!(module) normalized = Macro.underscore(name) - fun = String.to_existing_atom(normalized) - if function_exported?(module, fun, 3) do + fun = + try do + String.to_existing_atom(normalized) + rescue + ArgumentError -> nil + end + + if fun && function_exported?(module, fun, 3) do fun else exported = diff --git a/test/microwaveprop/cache_test.exs b/test/microwaveprop/cache_test.exs index e6c7f045..be5414c8 100644 --- a/test/microwaveprop/cache_test.exs +++ b/test/microwaveprop/cache_test.exs @@ -46,7 +46,7 @@ defmodule Microwaveprop.CacheTest do describe "sweep/0" do test "removes expired entries" do - Cache.put(:expired, "old", 1) + Cache.put(:expired, "old", -1) Cache.sweep() assert :ets.lookup(:microwaveprop_cache, :expired) == [] @@ -61,8 +61,8 @@ defmodule Microwaveprop.CacheTest do end test "handles a mix of expired and unexpired entries" do - Cache.put(:expired_a, 1, 1) - Cache.put(:expired_b, 2, 1) + Cache.put(:expired_a, 1, -1) + Cache.put(:expired_b, 2, -1) Cache.put(:fresh, 3, 60_000) Cache.sweep() diff --git a/test/microwaveprop_web/live/contact_live/index_test.exs b/test/microwaveprop_web/live/contact_live/index_test.exs index 10488be6..1d25cfd1 100644 --- a/test/microwaveprop_web/live/contact_live/index_test.exs +++ b/test/microwaveprop_web/live/contact_live/index_test.exs @@ -120,6 +120,8 @@ defmodule MicrowavepropWeb.ContactLive.IndexTest do create_contact(%{qso_timestamp: ~U[2026-03-22 12:00:00Z], station1: "K5MAR"}) create_contact(%{qso_timestamp: ~U[2026-07-10 12:00:00Z], station1: "W5JUL"}) + Microwaveprop.Cache.invalidate({MicrowavepropWeb.ContactLive.Index, :monthly_bars}) + {:ok, _lv, html} = live(conn, ~p"/contacts") assert html =~ "Contacts by month" diff --git a/test/microwaveprop_web/live/rover_locations_live/map_test.exs b/test/microwaveprop_web/live/rover_locations_live/map_test.exs index 23b046cd..debf307e 100644 --- a/test/microwaveprop_web/live/rover_locations_live/map_test.exs +++ b/test/microwaveprop_web/live/rover_locations_live/map_test.exs @@ -14,6 +14,9 @@ defmodule MicrowavepropWeb.RoverLocationsLive.MapTest do # `bad` rows must NOT show up on the map. {:ok, _} = Rover.create_location(user, %{lat: 34.0, lon: -99.0, status: :bad}) + # Invalidate the cached points so the LiveView picks up our test data. + Microwaveprop.Cache.invalidate({MicrowavepropWeb.RoverLocationsLive.Map, :points}) + {:ok, lv, html} = live(conn, ~p"/rover-locations/map") assert html =~ "Rover Locations Map" diff --git a/test/microwaveprop_web/live/status_live_test.exs b/test/microwaveprop_web/live/status_live_test.exs index e6e1d03d..a5039097 100644 --- a/test/microwaveprop_web/live/status_live_test.exs +++ b/test/microwaveprop_web/live/status_live_test.exs @@ -1,6 +1,7 @@ defmodule MicrowavepropWeb.StatusLiveTest do use MicrowavepropWeb.ConnCase, async: false + import Ecto.Query import Microwaveprop.AccountsFixtures import Phoenix.LiveViewTest @@ -15,6 +16,16 @@ defmodule MicrowavepropWeb.StatusLiveTest do Cache.invalidate({MicrowavepropWeb.StatusLive, :db_stats}) Cache.invalidate({MicrowavepropWeb.StatusLive, :grid_tasks_stats}) + # Wipe grid_tasks, contacts, and NARR profiles so tests don't interfere + Repo.delete_all(from(t in "grid_tasks", where: true)) + Repo.delete_all(from(p in NarrProfile, where: true)) + # Only delete contacts we created in tests — not the entire table + Repo.delete_all( + from(c in Contact, + where: c.station1 in ["W5TEST", "W5OFF", "W5QUE", "W5POST", "W5PUB", "W5PRV", "W5MAR", "K5MAR", "W5JUL"] + ) + ) + user = user_fixture() {:ok, admin} = user |> Ecto.Changeset.change(%{is_admin: true}) |> Repo.update() conn = log_in_user(conn, admin) @@ -78,19 +89,23 @@ defmodule MicrowavepropWeb.StatusLiveTest do end end - defp create_narr_candidate do + defp create_narr_candidate(attrs \\ []) do + lat = Keyword.get(attrs, :lat, 33.0) + lon = Keyword.get(attrs, :lon, -97.0) + ts = Keyword.get(attrs, :qso_timestamp, ~U[2010-06-15 18:00:00Z]) + {:ok, contact} = %Contact{} |> Contact.changeset(%{ station1: "W5TEST", station2: "K5TEST", - qso_timestamp: ~U[2010-06-15 18:00:00Z], + qso_timestamp: ts, mode: "CW", band: Decimal.new("1296"), grid1: "EM12", grid2: "EM00", - pos1: %{"lat" => 33.0, "lon" => -97.0}, - pos2: %{"lat" => 30.3, "lon" => -97.7}, + pos1: %{"lat" => lat, "lon" => lon}, + pos2: %{"lat" => lat + 0.1, "lon" => lon + 0.1}, distance_km: Decimal.new("300") }) |> Ecto.Changeset.change(%{hrrr_status: :unavailable}) @@ -102,7 +117,7 @@ defmodule MicrowavepropWeb.StatusLiveTest do # NARR fetches land in era5_profiles (table rename is a follow-up migration). # The status UI labels this "NARR" now. test "NARR progress bar numerator is actual profile matches not total-minus-candidates", %{conn: conn} do - create_narr_candidate() + create_narr_candidate(lat: 33.1, lon: -97.1) # No NARR profile yet — candidates = 1, done = 0 {:ok, lv, _html} = live(conn, ~p"/status") @@ -114,8 +129,8 @@ defmodule MicrowavepropWeb.StatusLiveTest do {:ok, _} = Repo.insert(%NarrProfile{ valid_time: ~U[2010-06-15 18:00:00Z], - lat: 33.0, - lon: -97.0, + lat: 33.1, + lon: -97.1, ducting_detected: false }) @@ -131,7 +146,7 @@ defmodule MicrowavepropWeb.StatusLiveTest do # not a NARR candidate, and must not inflate the NARR denominator. test "NARR progress excludes post-2014 hrrr-unavailable contacts", %{conn: conn} do # pre-2014 candidate (counts toward NARR) - create_narr_candidate() + create_narr_candidate(lat: 33.2, lon: -97.2) # post-2014 hrrr-unavailable contact (does NOT count toward NARR) {:ok, _} = @@ -144,8 +159,8 @@ defmodule MicrowavepropWeb.StatusLiveTest do band: Decimal.new("1296"), grid1: "EM12", grid2: "EM00", - pos1: %{"lat" => 33.0, "lon" => -97.0}, - pos2: %{"lat" => 30.3, "lon" => -97.7}, + pos1: %{"lat" => 33.2, "lon" => -97.2}, + pos2: %{"lat" => 33.3, "lon" => -97.3}, distance_km: Decimal.new("300") }) |> Ecto.Changeset.change(%{hrrr_status: :unavailable}) @@ -174,8 +189,8 @@ defmodule MicrowavepropWeb.StatusLiveTest do band: Decimal.new("1296"), grid1: "EM12", grid2: "EM00", - pos1: %{"lat" => 33.0, "lon" => -97.0}, - pos2: %{"lat" => 30.3, "lon" => -97.7}, + pos1: %{"lat" => 33.3, "lon" => -97.3}, + pos2: %{"lat" => 33.4, "lon" => -97.4}, distance_km: Decimal.new("300") }) |> Ecto.Changeset.change(%{hrrr_status: :unavailable}) @@ -184,8 +199,8 @@ defmodule MicrowavepropWeb.StatusLiveTest do {:ok, _} = Repo.insert(%NarrProfile{ valid_time: ~U[2010-06-15 18:00:00Z], - lat: 33.0, - lon: -97.0, + lat: 33.3, + lon: -97.3, ducting_detected: false }) @@ -209,8 +224,8 @@ defmodule MicrowavepropWeb.StatusLiveTest do band: Decimal.new("1296"), grid1: "EM12", grid2: "EM00", - pos1: %{"lat" => 33.0, "lon" => -97.0}, - pos2: %{"lat" => 30.3, "lon" => -97.7}, + pos1: %{"lat" => 33.4, "lon" => -97.4}, + pos2: %{"lat" => 33.5, "lon" => -97.5}, distance_km: Decimal.new("300") }) |> Ecto.Changeset.change(%{hrrr_status: :queued})