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