fix: resolve 7 pre-existing test failures and ScoreCache DateTime bug

- 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
This commit is contained in:
Graham McIntire 2026-06-10 13:50:24 -05:00
parent aab3c3736c
commit 9db2cd20f5
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 106 additions and 38 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 =

View file

@ -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()

View file

@ -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"

View file

@ -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"

View file

@ -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})