Profile page:
* New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
page showing a user's contacts and beacons. Resolves case-
insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
callsigns redirect to /. Uses daisyUI card / stats / table
components with an avatar-placeholder initial and hero icons.
* Accounts.get_user_by_callsign/1 (case-insensitive) plus
Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
back the page. Beacons list includes both approved and pending so
owners see their drafts.
* The top nav bar and the three LiveView sidebars (MapLive,
WeatherMapLive, ContactMapLive) now render the logged-in callsign
as a navigate link to /u/:callsign instead of a static label.
* Nine new tests cover the lookup, the LiveView render, and the
ownership-scoped queries.
Flexible band input:
* New Microwaveprop.Radio.BandResolver module converts any of:
ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
insensitive), numeric frequency strings ("903.100", "10368.000"),
and canonical MHz integers into the one of the site's known bands.
Returns the nearest allowed band for numeric inputs >= 900 MHz,
nil otherwise.
* 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
AdifImport.@allowed_bands, and the BandResolver list so "33cm"
round-trips end-to-end.
* AdifImport and CsvImport now delegate band resolution to
BandResolver, and Radio.create_contact/2 normalizes the :band attr
on the way in so the manual form and any API callers benefit too.
CsvImport's "invalid band" tests previously used 99999 MHz which
the new resolver snaps to the nearest allowed band; swapped to
"notaband" which is truly unresolvable.
Contacts and beacons list UX:
* Remove the "Submitted" column from /contacts — it duplicated info
already visible on the detail page and was pushing the real
columns off narrow viewports. submitted_cell/1 and its three
column-specific tests go with it.
* Hide the Lat / Lon columns from /beacons — six decimal places of
coordinates weren't useful next to the grid square and took a
disproportionate amount of row width.
501 lines
15 KiB
Elixir
501 lines
15 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
|
|
end
|