prop/test/microwaveprop/radio_test.exs
Graham McIntire 8a969e315c
refactor: normalize pos1/pos2 JSONB key to 'lon' everywhere
57,186 prod contacts stored pos1/pos2 with 'lng'; 1,133 used 'lon'.
Every Elixir caller carried a `pos["lon"] || pos["lng"]` fallback
— which just caused a SQL widget to silently miscount 98% of contacts
(count_narr_done used `pos1->>'lon'` directly, no fallback, so every
lng-keyed row returned NULL and failed the coverage check).

- Migration rewrites every pos1/pos2 JSONB in place, renaming 'lng' to
  'lon' and dropping 'lng'.
- Removes all 20+ `|| pos["lng"]` fallbacks across lib/, workers,
  scorer, weather, radio.ex, contact show view, and recalibrator.
- lib_ml/propagation_analyze.ex SQL now reads pos1->>'lon' directly
  (was reading 'lng' only, which would have broken after migration).
- priv/repo/import_contacts.exs one-time seed script now emits 'lon'
  with string keys, matching production shape.
- Test fixtures in 4 test files normalized to 'lon'.
- Two lng-characterization tests deleted — nonsensical post-normalize.
- Updated notebook + old import_weather script to match.
- JS hook contact_map_hook.ts TypeScript type narrowed to 'lon'.
2026-04-17 09:10:32 -05:00

574 lines
17 KiB
Elixir

defmodule Microwaveprop.RadioTest do
use Microwaveprop.DataCase, async: true
import Microwaveprop.AccountsFixtures
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.merge(default, attrs))
|> Repo.insert()
contact
end
describe "list_contacts/1" do
test "returns empty page when no QSOs exist" do
result = Radio.list_contacts()
assert result.entries == []
assert result.page == 1
assert result.total_pages == 1
assert result.total_entries == 0
end
test "paginates 20 per page, ordered by qso_timestamp desc" do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
end
result = Radio.list_contacts()
assert length(result.entries) == 20
assert result.page == 1
assert result.total_pages == 2
assert result.total_entries == 25
# Most recent first
first = hd(result.entries)
assert first.qso_timestamp == DateTime.add(~U[2026-01-01 00:00:00Z], 25 * 3600, :second)
end
test "returns second page" do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
end
result = Radio.list_contacts(page: 2)
assert length(result.entries) == 5
assert result.page == 2
assert result.total_pages == 2
end
end
describe "list_contacts/1 sorting" do
test "sorts by station1 ascending" do
create_contact(%{station1: "ZZ9ZZ"})
create_contact(%{station1: "AA1AA"})
result = Radio.list_contacts(sort_by: :station1, sort_order: :asc)
stations = Enum.map(result.entries, & &1.station1)
assert stations == ["AA1AA", "ZZ9ZZ"]
end
test "sorts by distance_km descending" do
create_contact(%{distance_km: Decimal.new("100"), station1: "A1A"})
create_contact(%{distance_km: Decimal.new("500"), station1: "B2B"})
result = Radio.list_contacts(sort_by: :distance_km, sort_order: :desc)
distances = Enum.map(result.entries, & &1.distance_km)
assert distances == [Decimal.new("500"), Decimal.new("100")]
end
test "invalid sort_by falls back to qso_timestamp desc" do
early = create_contact(%{qso_timestamp: ~U[2026-01-01 00:00:00Z]})
late = create_contact(%{qso_timestamp: ~U[2026-03-01 00:00:00Z]})
result = Radio.list_contacts(sort_by: :bogus)
ids = Enum.map(result.entries, & &1.id)
assert ids == [late.id, early.id]
end
end
describe "unprocessed_contacts/1" do
test "returns QSOs where weather_queued is false and pos1 is not nil" do
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
_q2 = create_contact(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
results = Radio.unprocessed_contacts()
ids = Enum.map(results, & &1.id)
assert q1.id in ids
end
test "excludes QSOs already marked as weather_status" do
q = create_contact()
Radio.mark_weather_queued!([q.id])
assert Radio.unprocessed_contacts() == []
end
test "orders by qso_timestamp ascending" do
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
results = Radio.unprocessed_contacts()
ids = Enum.map(results, & &1.id)
assert ids == [q_early.id, q_late.id]
end
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
end
assert length(Radio.unprocessed_contacts(3)) == 3
end
end
describe "mark_weather_queued!/1" do
test "sets weather_queued to true for given IDs" do
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_weather_queued!([q1.id, q2.id])
assert Repo.get!(Contact, q1.id).weather_status == :queued
assert Repo.get!(Contact, q2.id).weather_status == :queued
end
test "does not affect other QSOs" do
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_weather_queued!([q1.id])
assert Repo.get!(Contact, q1.id).weather_status == :queued
assert Repo.get!(Contact, q2.id).weather_status == :pending
end
test "handles empty list" do
assert Radio.mark_weather_queued!([]) == {0, nil}
end
end
describe "haversine_km/4" do
test "calculates distance between two known points" do
# Dallas to Austin ~roughly 295 km
dist = Radio.haversine_km(32.9, -97.0, 30.3, -97.7)
assert_in_delta dist, 295, 5
end
test "returns 0 for same point" do
assert Radio.haversine_km(32.9, -97.0, 32.9, -97.0) == 0.0
end
end
describe "backfill_distances/1" do
test "calculates and stores distance for QSOs missing distance_km" do
contact = create_contact(%{distance_km: nil})
assert is_nil(contact.distance_km)
Radio.backfill_distances([contact])
updated = Repo.get!(Contact, contact.id)
assert_in_delta Decimal.to_float(updated.distance_km), 295, 5
end
test "skips QSOs that already have a distance" do
contact = create_contact(%{distance_km: Decimal.new("100")})
Radio.backfill_distances([contact])
updated = Repo.get!(Contact, contact.id)
assert updated.distance_km == Decimal.new("100")
end
test "skips QSOs missing pos2" do
contact = create_contact(%{distance_km: nil, pos2: nil})
Radio.backfill_distances([contact])
updated = Repo.get!(Contact, contact.id)
assert is_nil(updated.distance_km)
end
end
describe "get_contact!/1" do
test "returns a QSO by ID" do
contact = create_contact()
found = Radio.get_contact!(contact.id)
assert found.id == contact.id
assert found.station1 == "W5XD"
end
test "raises Ecto.NoResultsError on bad ID" do
assert_raise Ecto.NoResultsError, fn ->
Radio.get_contact!(Ecto.UUID.generate())
end
end
end
describe "unprocessed_hrrr_contacts/1" do
test "returns QSOs where hrrr_queued is false and pos1 is not nil" do
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
_q2 = create_contact(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
results = Radio.unprocessed_hrrr_contacts()
ids = Enum.map(results, & &1.id)
assert q1.id in ids
end
test "excludes QSOs already marked as hrrr_status" do
q = create_contact()
Radio.mark_hrrr_queued!([q.id])
assert Radio.unprocessed_hrrr_contacts() == []
end
test "orders by qso_timestamp ascending" do
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
results = Radio.unprocessed_hrrr_contacts()
ids = Enum.map(results, & &1.id)
assert ids == [q_early.id, q_late.id]
end
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
end
assert length(Radio.unprocessed_hrrr_contacts(3)) == 3
end
end
describe "mark_hrrr_queued!/1" do
test "sets hrrr_queued to true for given IDs" do
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_hrrr_queued!([q1.id, q2.id])
assert Repo.get!(Contact, q1.id).hrrr_status == :queued
assert Repo.get!(Contact, q2.id).hrrr_status == :queued
end
test "does not affect other QSOs" do
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_hrrr_queued!([q1.id])
assert Repo.get!(Contact, q1.id).hrrr_status == :queued
assert Repo.get!(Contact, q2.id).hrrr_status == :pending
end
test "handles empty list" do
assert Radio.mark_hrrr_queued!([]) == {0, nil}
end
end
describe "unprocessed_terrain_contacts/1" do
test "returns QSOs where terrain_queued is false and both pos1 and pos2 are not nil" do
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
_q2 = create_contact(%{station1: "NOPOS1", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
_q3 = create_contact(%{station1: "NOPOS2", pos2: nil, qso_timestamp: ~U[2026-03-28 14:00:00Z]})
results = Radio.unprocessed_terrain_contacts()
ids = Enum.map(results, & &1.id)
assert q1.id in ids
assert length(ids) == 1
end
test "excludes QSOs already marked as terrain_status" do
q = create_contact()
Radio.mark_terrain_queued!([q.id])
assert Radio.unprocessed_terrain_contacts() == []
end
test "orders by qso_timestamp ascending" do
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
results = Radio.unprocessed_terrain_contacts()
ids = Enum.map(results, & &1.id)
assert ids == [q_early.id, q_late.id]
end
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
end
assert length(Radio.unprocessed_terrain_contacts(3)) == 3
end
end
describe "mark_terrain_queued!/1" do
test "sets terrain_queued to true for given IDs" do
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_terrain_queued!([q1.id, q2.id])
assert Repo.get!(Contact, q1.id).terrain_status == :queued
assert Repo.get!(Contact, q2.id).terrain_status == :queued
end
test "does not affect other QSOs" do
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_terrain_queued!([q1.id])
assert Repo.get!(Contact, q1.id).terrain_status == :queued
assert Repo.get!(Contact, q2.id).terrain_status == :pending
end
test "handles empty list" do
assert Radio.mark_terrain_queued!([]) == {0, nil}
end
end
describe "contact_path_points/1" do
test "returns pos1, midpoint, and pos2 when both positions exist" do
contact =
create_contact(%{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
})
points = Radio.contact_path_points(contact)
assert length(points) == 3
[{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}] = points
assert lat1 == 32.9
assert lon1 == -97.0
assert_in_delta mid_lat, 31.6, 0.01
assert_in_delta mid_lon, -97.35, 0.01
assert lat2 == 30.3
assert lon2 == -97.7
end
test "returns only pos1 when pos2 is nil" do
contact = create_contact(%{pos2: nil})
points = Radio.contact_path_points(contact)
assert points == [{32.9, -97.0}]
end
test "returns empty list when pos1 is nil" do
contact = create_contact(%{pos1: nil})
assert Radio.contact_path_points(contact) == []
end
test "handles lon key in position maps" do
contact =
create_contact(%{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
})
points = Radio.contact_path_points(contact)
assert length(points) == 3
end
end
describe "change_contact/2" do
test "returns a submission changeset" do
changeset = Radio.change_contact(%Contact{})
assert %Ecto.Changeset{} = changeset
end
test "accepts attrs" do
changeset = Radio.change_contact(%Contact{}, %{station1: "W5XD"})
assert Ecto.Changeset.get_change(changeset, :station1) == "W5XD"
end
end
describe "create_contact/1" do
@valid_submission %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: "1296",
grid1: "EM12",
grid2: "EM00",
submitter_email: "test@example.com"
}
test "creates QSO with computed positions and distance" do
assert {:ok, contact} = Radio.create_contact(@valid_submission)
assert contact.station1 == "W5XD"
assert contact.user_submitted == true
assert contact.pos1["lat"]
assert contact.pos1["lon"]
assert contact.pos2["lat"]
assert contact.pos2["lon"]
assert contact.distance_km
end
test "computes correct position from grid" do
assert {:ok, contact} = Radio.create_contact(@valid_submission)
# EM12 center: lat = 4*10 - 90 + 1*1 + 0.5 = -48.5... wait
# E=4, M=12 -> lon = 4*20 - 180 + 1*2 + 1 = -97, lat = 12*10 - 90 + 2*1 + 0.5 = 32.5
assert_in_delta contact.pos1["lat"], 32.5, 0.1
assert_in_delta contact.pos1["lon"], -97.0, 0.1
end
test "sets user_submitted to true" do
assert {:ok, contact} = Radio.create_contact(@valid_submission)
assert contact.user_submitted == true
end
test "returns error changeset for invalid data" do
assert {:error, %Ecto.Changeset{}} = Radio.create_contact(%{})
end
test "returns error changeset for invalid grid" do
attrs = Map.put(@valid_submission, :grid1, "ZZ99")
assert {:error, changeset} = Radio.create_contact(attrs)
assert errors_on(changeset).grid1
end
end
describe "list_contacts_for_user/1" do
test "returns only contacts whose user_id matches the user" do
user = user_fixture()
other = user_fixture()
owned =
create_contact(%{station1: "W5OWN", user_id: user.id})
_their =
create_contact(%{station1: "W5THR", user_id: other.id})
_anon = create_contact(%{station1: "W5ANO"})
assert [found] = Radio.list_contacts_for_user(user)
assert found.id == owned.id
end
test "orders newest first" do
user = user_fixture()
_old =
create_contact(%{
qso_timestamp: ~U[2020-01-01 00:00:00Z],
user_id: user.id,
station1: "W5OLD"
})
new =
create_contact(%{
qso_timestamp: ~U[2026-06-15 12:00:00Z],
user_id: user.id,
station1: "W5NEW"
})
assert [first, _] = Radio.list_contacts_for_user(user)
assert first.id == new.id
end
test "returns an empty list when the user has submitted nothing" do
user = user_fixture()
assert Radio.list_contacts_for_user(user) == []
end
end
describe "list_contacts_involving_callsign/1" do
test "returns contacts where the callsign is station1" do
match = create_contact(%{station1: "W5ISP", station2: "K5OTR"})
_other = create_contact(%{station1: "W5OTH", station2: "K5OTR"})
assert [found] = Radio.list_contacts_involving_callsign("W5ISP")
assert found.id == match.id
end
test "returns contacts where the callsign is station2" do
match = create_contact(%{station1: "W5OTH", station2: "W5ISP"})
_other = create_contact(%{station1: "W5OTH", station2: "K5OTR"})
assert [found] = Radio.list_contacts_involving_callsign("W5ISP")
assert found.id == match.id
end
test "matching is case-insensitive" do
match = create_contact(%{station1: "W5ISP", station2: "K5OTR"})
assert [found] = Radio.list_contacts_involving_callsign("w5isp")
assert found.id == match.id
end
test "orders newest first" do
_old =
create_contact(%{
station1: "W5ISP",
station2: "K5A",
qso_timestamp: ~U[2020-01-01 00:00:00Z]
})
new =
create_contact(%{
station1: "K5B",
station2: "W5ISP",
qso_timestamp: ~U[2026-06-15 12:00:00Z]
})
assert [first, _] = Radio.list_contacts_involving_callsign("W5ISP")
assert first.id == new.id
end
test "returns empty list when no contacts match" do
create_contact(%{station1: "W5OTH", station2: "K5OTR"})
assert Radio.list_contacts_involving_callsign("W5ISP") == []
end
test "returns empty list for blank or nil callsign" do
assert Radio.list_contacts_involving_callsign("") == []
assert Radio.list_contacts_involving_callsign(nil) == []
end
test "caps the result at the 100 most recent contacts" do
base = ~U[2020-01-01 00:00:00Z]
for i <- 1..120 do
create_contact(%{
station1: "W5ISP",
station2: "K5X#{i}",
qso_timestamp: DateTime.add(base, i * 3600, :second)
})
end
result = Radio.list_contacts_involving_callsign("W5ISP")
assert length(result) == 100
# The 100 newest by qso_timestamp correspond to i = 21..120; i=120 is newest.
assert hd(result).station2 == "K5X120"
end
end
end