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:
parent
aab3c3736c
commit
9db2cd20f5
8 changed files with 106 additions and 38 deletions
|
|
@ -529,10 +529,9 @@ defmodule Microwaveprop.Accounts do
|
||||||
defp update_user_and_delete_all_tokens(changeset) do
|
defp update_user_and_delete_all_tokens(changeset) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
with {:ok, user} <- Repo.update(changeset) do
|
with {:ok, user} <- Repo.update(changeset) do
|
||||||
{tokens_to_expire, _} =
|
Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id))
|
||||||
Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id), returning: true)
|
|
||||||
|
|
||||||
{:ok, {user, tokens_to_expire}}
|
{:ok, {user, []}}
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,36 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do
|
||||||
|
|
||||||
# HRRR forecast hours: f01-f18 hourly, then f21-f48 3-hourly.
|
# HRRR forecast hours: f01-f18 hourly, then f21-f48 3-hourly.
|
||||||
# Rust processes whatever rows are seeded — no code changes needed there.
|
# 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
|
# A `running` row older than this is an orphan: the Rust worker that
|
||||||
# claimed it was SIGKILLed / OOMed / evicted before it could emit a
|
# claimed it was SIGKILLed / OOMed / evicted before it could emit a
|
||||||
|
|
|
||||||
|
|
@ -162,11 +162,21 @@ defmodule Microwaveprop.Propagation.ScoreCache do
|
||||||
|
|
||||||
@spec prune_older_than(DateTime.t()) :: non_neg_integer()
|
@spec prune_older_than(DateTime.t()) :: non_neg_integer()
|
||||||
def prune_older_than(cutoff) do
|
def prune_older_than(cutoff) do
|
||||||
match_spec = [
|
# Use DateTime.compare in Elixir rather than ETS match-spec guards —
|
||||||
{{{:_, :"$1"}, :_}, [{:<, :"$1", {:const, cutoff}}], [true]}
|
# DateTime structs are maps in Elixir ≥ 1.15 and ETS can't compare
|
||||||
]
|
# maps with :< / :> operators.
|
||||||
|
:ets.foldl(
|
||||||
:ets.select_delete(@table, match_spec)
|
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
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -177,14 +187,18 @@ defmodule Microwaveprop.Propagation.ScoreCache do
|
||||||
"""
|
"""
|
||||||
@spec prune_outside_window(DateTime.t(), DateTime.t()) :: non_neg_integer()
|
@spec prune_outside_window(DateTime.t(), DateTime.t()) :: non_neg_integer()
|
||||||
def prune_outside_window(past_cutoff, future_cutoff) do
|
def prune_outside_window(past_cutoff, future_cutoff) do
|
||||||
match_spec = [
|
:ets.foldl(
|
||||||
{{{:_, :"$1"}, :_},
|
fn {{band, vt}, _val}, acc ->
|
||||||
[
|
if DateTime.before?(vt, past_cutoff) or DateTime.after?(vt, future_cutoff) do
|
||||||
{:orelse, {:<, :"$1", {:const, past_cutoff}}, {:>, :"$1", {:const, future_cutoff}}}
|
:ets.delete(@table, {band, vt})
|
||||||
], [true]}
|
acc + 1
|
||||||
]
|
else
|
||||||
|
acc
|
||||||
:ets.select_delete(@table, match_spec)
|
end
|
||||||
|
end,
|
||||||
|
0,
|
||||||
|
@table
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec clear() :: :ok
|
@spec clear() :: :ok
|
||||||
|
|
|
||||||
|
|
@ -122,9 +122,15 @@ defmodule Mix.Tasks.Backtest do
|
||||||
defp resolve_function_atom!(module, name) do
|
defp resolve_function_atom!(module, name) do
|
||||||
Code.ensure_loaded!(module)
|
Code.ensure_loaded!(module)
|
||||||
normalized = Macro.underscore(name)
|
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
|
fun
|
||||||
else
|
else
|
||||||
exported =
|
exported =
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ defmodule Microwaveprop.CacheTest do
|
||||||
|
|
||||||
describe "sweep/0" do
|
describe "sweep/0" do
|
||||||
test "removes expired entries" do
|
test "removes expired entries" do
|
||||||
Cache.put(:expired, "old", 1)
|
Cache.put(:expired, "old", -1)
|
||||||
Cache.sweep()
|
Cache.sweep()
|
||||||
|
|
||||||
assert :ets.lookup(:microwaveprop_cache, :expired) == []
|
assert :ets.lookup(:microwaveprop_cache, :expired) == []
|
||||||
|
|
@ -61,8 +61,8 @@ defmodule Microwaveprop.CacheTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles a mix of expired and unexpired entries" do
|
test "handles a mix of expired and unexpired entries" do
|
||||||
Cache.put(:expired_a, 1, 1)
|
Cache.put(:expired_a, 1, -1)
|
||||||
Cache.put(:expired_b, 2, 1)
|
Cache.put(:expired_b, 2, -1)
|
||||||
Cache.put(:fresh, 3, 60_000)
|
Cache.put(:fresh, 3, 60_000)
|
||||||
Cache.sweep()
|
Cache.sweep()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-03-22 12:00:00Z], station1: "K5MAR"})
|
||||||
create_contact(%{qso_timestamp: ~U[2026-07-10 12:00:00Z], station1: "W5JUL"})
|
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")
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
|
|
||||||
assert html =~ "Contacts by month"
|
assert html =~ "Contacts by month"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ defmodule MicrowavepropWeb.RoverLocationsLive.MapTest do
|
||||||
# `bad` rows must NOT show up on the map.
|
# `bad` rows must NOT show up on the map.
|
||||||
{:ok, _} = Rover.create_location(user, %{lat: 34.0, lon: -99.0, status: :bad})
|
{: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")
|
{:ok, lv, html} = live(conn, ~p"/rover-locations/map")
|
||||||
|
|
||||||
assert html =~ "Rover Locations Map"
|
assert html =~ "Rover Locations Map"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
defmodule MicrowavepropWeb.StatusLiveTest do
|
defmodule MicrowavepropWeb.StatusLiveTest do
|
||||||
use MicrowavepropWeb.ConnCase, async: false
|
use MicrowavepropWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
import Microwaveprop.AccountsFixtures
|
import Microwaveprop.AccountsFixtures
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
|
@ -15,6 +16,16 @@ defmodule MicrowavepropWeb.StatusLiveTest do
|
||||||
Cache.invalidate({MicrowavepropWeb.StatusLive, :db_stats})
|
Cache.invalidate({MicrowavepropWeb.StatusLive, :db_stats})
|
||||||
Cache.invalidate({MicrowavepropWeb.StatusLive, :grid_tasks_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()
|
user = user_fixture()
|
||||||
{:ok, admin} = user |> Ecto.Changeset.change(%{is_admin: true}) |> Repo.update()
|
{:ok, admin} = user |> Ecto.Changeset.change(%{is_admin: true}) |> Repo.update()
|
||||||
conn = log_in_user(conn, admin)
|
conn = log_in_user(conn, admin)
|
||||||
|
|
@ -78,19 +89,23 @@ defmodule MicrowavepropWeb.StatusLiveTest do
|
||||||
end
|
end
|
||||||
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} =
|
{:ok, contact} =
|
||||||
%Contact{}
|
%Contact{}
|
||||||
|> Contact.changeset(%{
|
|> Contact.changeset(%{
|
||||||
station1: "W5TEST",
|
station1: "W5TEST",
|
||||||
station2: "K5TEST",
|
station2: "K5TEST",
|
||||||
qso_timestamp: ~U[2010-06-15 18:00:00Z],
|
qso_timestamp: ts,
|
||||||
mode: "CW",
|
mode: "CW",
|
||||||
band: Decimal.new("1296"),
|
band: Decimal.new("1296"),
|
||||||
grid1: "EM12",
|
grid1: "EM12",
|
||||||
grid2: "EM00",
|
grid2: "EM00",
|
||||||
pos1: %{"lat" => 33.0, "lon" => -97.0},
|
pos1: %{"lat" => lat, "lon" => lon},
|
||||||
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
pos2: %{"lat" => lat + 0.1, "lon" => lon + 0.1},
|
||||||
distance_km: Decimal.new("300")
|
distance_km: Decimal.new("300")
|
||||||
})
|
})
|
||||||
|> Ecto.Changeset.change(%{hrrr_status: :unavailable})
|
|> 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).
|
# NARR fetches land in era5_profiles (table rename is a follow-up migration).
|
||||||
# The status UI labels this "NARR" now.
|
# The status UI labels this "NARR" now.
|
||||||
test "NARR progress bar numerator is actual profile matches not total-minus-candidates", %{conn: conn} do
|
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
|
# No NARR profile yet — candidates = 1, done = 0
|
||||||
{:ok, lv, _html} = live(conn, ~p"/status")
|
{:ok, lv, _html} = live(conn, ~p"/status")
|
||||||
|
|
@ -114,8 +129,8 @@ defmodule MicrowavepropWeb.StatusLiveTest do
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
Repo.insert(%NarrProfile{
|
Repo.insert(%NarrProfile{
|
||||||
valid_time: ~U[2010-06-15 18:00:00Z],
|
valid_time: ~U[2010-06-15 18:00:00Z],
|
||||||
lat: 33.0,
|
lat: 33.1,
|
||||||
lon: -97.0,
|
lon: -97.1,
|
||||||
ducting_detected: false
|
ducting_detected: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -131,7 +146,7 @@ defmodule MicrowavepropWeb.StatusLiveTest do
|
||||||
# not a NARR candidate, and must not inflate the NARR denominator.
|
# not a NARR candidate, and must not inflate the NARR denominator.
|
||||||
test "NARR progress excludes post-2014 hrrr-unavailable contacts", %{conn: conn} do
|
test "NARR progress excludes post-2014 hrrr-unavailable contacts", %{conn: conn} do
|
||||||
# pre-2014 candidate (counts toward NARR)
|
# 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)
|
# post-2014 hrrr-unavailable contact (does NOT count toward NARR)
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
|
|
@ -144,8 +159,8 @@ defmodule MicrowavepropWeb.StatusLiveTest do
|
||||||
band: Decimal.new("1296"),
|
band: Decimal.new("1296"),
|
||||||
grid1: "EM12",
|
grid1: "EM12",
|
||||||
grid2: "EM00",
|
grid2: "EM00",
|
||||||
pos1: %{"lat" => 33.0, "lon" => -97.0},
|
pos1: %{"lat" => 33.2, "lon" => -97.2},
|
||||||
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
pos2: %{"lat" => 33.3, "lon" => -97.3},
|
||||||
distance_km: Decimal.new("300")
|
distance_km: Decimal.new("300")
|
||||||
})
|
})
|
||||||
|> Ecto.Changeset.change(%{hrrr_status: :unavailable})
|
|> Ecto.Changeset.change(%{hrrr_status: :unavailable})
|
||||||
|
|
@ -174,8 +189,8 @@ defmodule MicrowavepropWeb.StatusLiveTest do
|
||||||
band: Decimal.new("1296"),
|
band: Decimal.new("1296"),
|
||||||
grid1: "EM12",
|
grid1: "EM12",
|
||||||
grid2: "EM00",
|
grid2: "EM00",
|
||||||
pos1: %{"lat" => 33.0, "lon" => -97.0},
|
pos1: %{"lat" => 33.3, "lon" => -97.3},
|
||||||
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
pos2: %{"lat" => 33.4, "lon" => -97.4},
|
||||||
distance_km: Decimal.new("300")
|
distance_km: Decimal.new("300")
|
||||||
})
|
})
|
||||||
|> Ecto.Changeset.change(%{hrrr_status: :unavailable})
|
|> Ecto.Changeset.change(%{hrrr_status: :unavailable})
|
||||||
|
|
@ -184,8 +199,8 @@ defmodule MicrowavepropWeb.StatusLiveTest do
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
Repo.insert(%NarrProfile{
|
Repo.insert(%NarrProfile{
|
||||||
valid_time: ~U[2010-06-15 18:00:00Z],
|
valid_time: ~U[2010-06-15 18:00:00Z],
|
||||||
lat: 33.0,
|
lat: 33.3,
|
||||||
lon: -97.0,
|
lon: -97.3,
|
||||||
ducting_detected: false
|
ducting_detected: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -209,8 +224,8 @@ defmodule MicrowavepropWeb.StatusLiveTest do
|
||||||
band: Decimal.new("1296"),
|
band: Decimal.new("1296"),
|
||||||
grid1: "EM12",
|
grid1: "EM12",
|
||||||
grid2: "EM00",
|
grid2: "EM00",
|
||||||
pos1: %{"lat" => 33.0, "lon" => -97.0},
|
pos1: %{"lat" => 33.4, "lon" => -97.4},
|
||||||
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
pos2: %{"lat" => 33.5, "lon" => -97.5},
|
||||||
distance_km: Decimal.new("300")
|
distance_km: Decimal.new("300")
|
||||||
})
|
})
|
||||||
|> Ecto.Changeset.change(%{hrrr_status: :queued})
|
|> Ecto.Changeset.change(%{hrrr_status: :queued})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue