The profile page's involving-contacts query now limits to the 100 most recent rows (ordered by qso_timestamp desc). A small note is shown under the heading when the cap is hit so users know there's more data they're not seeing.
574 lines
17 KiB
Elixir
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 lng key in position maps" do
|
|
contact =
|
|
create_contact(%{
|
|
pos1: %{"lat" => 32.9, "lng" => -97.0},
|
|
pos2: %{"lat" => 30.3, "lng" => -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
|