diff --git a/test/microwaveprop/commercial/poll_worker_test.exs b/test/microwaveprop/commercial/poll_worker_test.exs index 7a5d0505..df7d398d 100644 --- a/test/microwaveprop/commercial/poll_worker_test.exs +++ b/test/microwaveprop/commercial/poll_worker_test.exs @@ -65,5 +65,36 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do assert length(Repo.all(Sample)) == 1 end + + test "no-ops cleanly on an empty link list" do + # Defensive: if the DB has no enabled links, the worker should + # still return without calling the poll fn or touching samples. + called? = :counters.new(1, [:atomics]) + + poll_fn = fn _host, _community, _type -> + :counters.add(called?, 1, 1) + {:ok, %{}} + end + + PollWorker.poll_and_record([], poll_fn) + + assert :counters.get(called?, 1) == 0 + assert Repo.all(Sample) == [] + end + + test "dispatches poll_fn with host, community, and radio_type from the link" do + {:ok, _link} = Commercial.create_link(@link_attrs) + + test_pid = self() + + poll_fn = fn host, community, radio_type -> + send(test_pid, {:poll_args, host, community, radio_type}) + {:error, :stop} + end + + PollWorker.poll_and_record(Commercial.enabled_links(), poll_fn) + + assert_receive {:poll_args, "10.0.0.1", "public", "af11x"}, 500 + end end end diff --git a/test/microwaveprop/propagation/grid_test.exs b/test/microwaveprop/propagation/grid_test.exs index 91e51190..e421a7f8 100644 --- a/test/microwaveprop/propagation/grid_test.exs +++ b/test/microwaveprop/propagation/grid_test.exs @@ -53,4 +53,32 @@ defmodule Microwaveprop.Propagation.GridTest do assert bounds.lon_max == -66.0 end end + + describe "wgrib2_grid_spec/0" do + test "lon_start / lat_start match the bounding box SW corner" do + spec = Grid.wgrib2_grid_spec() + assert spec.lon_start == -125.0 + assert spec.lat_start == 25.0 + end + + test "grid step matches Grid.step/0 on both axes" do + spec = Grid.wgrib2_grid_spec() + assert spec.lon_step == Grid.step() + assert spec.lat_step == Grid.step() + end + + test "lon_count and lat_count span the full CONUS box inclusive" do + spec = Grid.wgrib2_grid_spec() + # (lon_max - lon_min) / step + 1 = (-66 - -125) / 0.125 + 1 = 473 + assert spec.lon_count == 473 + # (lat_max - lat_min) / step + 1 = (50 - 25) / 0.125 + 1 = 201 + assert spec.lat_count == 201 + end + + test "total cells in wgrib2_grid_spec matches conus_points length" do + spec = Grid.wgrib2_grid_spec() + points = Grid.conus_points() + assert length(points) == spec.lon_count * spec.lat_count + end + end end diff --git a/test/microwaveprop/radio/contact_submission_test.exs b/test/microwaveprop/radio/contact_submission_test.exs index 53347d31..5f20a7be 100644 --- a/test/microwaveprop/radio/contact_submission_test.exs +++ b/test/microwaveprop/radio/contact_submission_test.exs @@ -114,6 +114,61 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do changeset = Contact.submission_changeset(%Contact{}, attrs) refute Ecto.Changeset.get_change(changeset, :user_submitted) end + + test "accepts antenna heights within [0, 1000] ft" do + for {h1, h2} <- [{0, 0}, {10, 10}, {500, 750}, {999, 1000}] do + attrs = Map.merge(@valid_attrs, %{height1_ft: h1, height2_ft: h2}) + changeset = Contact.submission_changeset(%Contact{}, attrs) + assert changeset.valid?, "expected heights h1=#{h1} h2=#{h2} to validate" + end + end + + test "rejects negative antenna heights" do + attrs = Map.put(@valid_attrs, :height1_ft, -5) + changeset = Contact.submission_changeset(%Contact{}, attrs) + assert errors_on(changeset)[:height1_ft] + end + + test "rejects antenna heights above 1000 ft" do + attrs = Map.put(@valid_attrs, :height2_ft, 1001) + changeset = Contact.submission_changeset(%Contact{}, attrs) + assert errors_on(changeset)[:height2_ft] + end + + test "collapses a whitespace-only mode to no-mode (not an invalid-mode error)" do + for blank <- ["", " ", "\t", "\n "] do + attrs = Map.put(@valid_attrs, :mode, blank) + changeset = Contact.submission_changeset(%Contact{}, attrs) + + assert changeset.valid?, "expected blank mode #{inspect(blank)} to validate" + refute errors_on(changeset)[:mode] + end + end + + property "accepts every sanctioned band" do + check all( + band <- + StreamData.member_of( + ~w(50 144 222 432 902 1296 2304 3400 5760 10000 24000 47000 68000 75000 122000 134000 241000) + ) + ) do + attrs = Map.put(@valid_attrs, :band, band) + changeset = Contact.submission_changeset(%Contact{}, attrs) + assert changeset.valid? + end + end + + property "rejects any band string outside the sanctioned list" do + check all( + band <- string(:alphanumeric, min_length: 1, max_length: 10), + band not in ~w(50 144 222 432 902 1296 2304 3400 5760 10000 24000 47000 68000 75000 122000 134000 241000) + ) do + attrs = Map.put(@valid_attrs, :band, band) + changeset = Contact.submission_changeset(%Contact{}, attrs) + refute changeset.valid? + assert errors_on(changeset)[:band] + end + end end describe "notes field" do diff --git a/test/microwaveprop/space_weather/swpc_client_test.exs b/test/microwaveprop/space_weather/swpc_client_test.exs index 3327e326..e335bdb2 100644 --- a/test/microwaveprop/space_weather/swpc_client_test.exs +++ b/test/microwaveprop/space_weather/swpc_client_test.exs @@ -84,5 +84,125 @@ defmodule Microwaveprop.SpaceWeather.SwpcClientTest do assert first.satellite == 18 assert_in_delta first.flux_wm2, 3.523e-07, 1.0e-09 end + + test "filters out rows whose energy band isn't the long-wavelength one" do + body = + Jason.encode!([ + %{ + "time_tag" => "2026-04-14T19:49:00Z", + "satellite" => 18, + "flux" => 1.0e-7, + "energy" => "0.05-0.4nm" + }, + %{ + "time_tag" => "2026-04-14T19:49:00Z", + "satellite" => 18, + "flux" => 3.523e-7, + "energy" => "0.1-0.8nm" + } + ]) + + assert {:ok, [row]} = SwpcClient.parse_xrays(body) + assert row.energy_band == "0.1-0.8nm" + end + + test "flags electron_contaminated when the (misspelled upstream) key is truthy" do + body = + Jason.encode!([ + %{ + "time_tag" => "2026-04-14T19:49:00Z", + "satellite" => 18, + "flux" => 3.5e-7, + "energy" => "0.1-0.8nm", + "electron_contaminaton" => true + } + ]) + + assert {:ok, [row]} = SwpcClient.parse_xrays(body) + assert row.electron_contaminated == true + end + + test "silently drops rows whose time_tag won't parse" do + body = + Jason.encode!([ + %{ + "time_tag" => "not-a-timestamp", + "satellite" => 18, + "flux" => 3.5e-7, + "energy" => "0.1-0.8nm" + }, + %{ + "time_tag" => "2026-04-14T19:49:00Z", + "satellite" => 18, + "flux" => 3.5e-7, + "energy" => "0.1-0.8nm" + } + ]) + + assert {:ok, [row]} = SwpcClient.parse_xrays(body) + assert row.valid_time == ~U[2026-04-14 19:49:00Z] + end + end + + describe "parse_* shared helpers" do + test "parse_kp drops rows whose time_tag is unparseable" do + body = + Jason.encode!([ + %{"time_tag" => "garbage", "kp_index" => 1}, + %{"time_tag" => "2026-04-15T13:49:00", "kp_index" => 3, "estimated_kp" => 3.67} + ]) + + assert {:ok, [row]} = SwpcClient.parse_kp(body) + assert row.kp_index == 3 + assert row.valid_time == ~U[2026-04-15 13:49:00Z] + end + + test "parse_kp tolerates integer and string numerics for kp_index" do + body = + Jason.encode!([ + %{"time_tag" => "2026-04-15T13:49:00", "kp_index" => "4", "estimated_kp" => "4.33"}, + %{"time_tag" => "2026-04-15T13:50:00", "kp_index" => 5, "estimated_kp" => 5.0} + ]) + + assert {:ok, rows} = SwpcClient.parse_kp(body) + assert Enum.map(rows, & &1.kp_index) == [4, 5] + assert Enum.map(rows, & &1.estimated_kp) == [4.33, 5.0] + end + + test "parse_f107 carries frequency, flux, schedule, and ninety_day_mean" do + body = + Jason.encode!([ + %{ + "time_tag" => "2026-04-15T17:00:00", + "frequency" => 2800, + "flux" => 142.4, + "reporting_schedule" => "Morning", + "ninety_day_mean" => 150.0 + } + ]) + + assert {:ok, [row]} = SwpcClient.parse_f107(body) + assert row.frequency_mhz == 2800 + assert row.flux_sfu == 142.4 + assert row.reporting_schedule == "Morning" + assert row.ninety_day_mean == 150.0 + end + + test "parse_* accept an already-decoded list body (Req auto-decode case)" do + list = [ + %{"time_tag" => "2026-04-15T13:49:00", "kp_index" => 2, "estimated_kp" => 2.33} + ] + + # parse_kp is the representative here — the shared decode_array + # helper delegates the list path for every product. + assert {:ok, [row]} = SwpcClient.parse_kp(list) + assert row.kp_index == 2 + end + + test "parse_* return {:error, :not_a_list} for JSON objects" do + assert {:error, :not_a_list} = SwpcClient.parse_kp(~s({"not":"list"})) + assert {:error, :not_a_list} = SwpcClient.parse_f107(~s({"not":"list"})) + assert {:error, :not_a_list} = SwpcClient.parse_xrays(~s({"not":"list"})) + end end end diff --git a/test/microwaveprop/weather/uwyo_sounding_client_test.exs b/test/microwaveprop/weather/uwyo_sounding_client_test.exs index a8e6626d..df849a63 100644 --- a/test/microwaveprop/weather/uwyo_sounding_client_test.exs +++ b/test/microwaveprop/weather/uwyo_sounding_client_test.exs @@ -119,4 +119,65 @@ defmodule Microwaveprop.Weather.UwyoSoundingClientTest do UwyoSoundingClient.fetch_sounding("YYR", ~U[2026-04-13 00:00:00Z]) end end + + describe "parse_sounding_html/1 edge cases" do + test "returns [] for each month abbreviation the header parser doesn't recognise" do + # Two valid-ish fake bodies with corrupt month tokens. The parser + # should skip both (no raise, no silent garbage sounding). + for bad_month <- ["Foo", "NotAMonth", "", "Marchington"] do + html = """ +
+ 1000.0 92 25.0 20.0 ++ """ + + assert [] = UwyoSoundingClient.parse_sounding_html(html) + end + end + + test "every month abbreviation routes to its correct number" do + months = [ + {"Jan", 1, ~U[2026-01-13 00:00:00Z]}, + {"Feb", 2, ~U[2026-02-13 00:00:00Z]}, + {"Mar", 3, ~U[2026-03-13 00:00:00Z]}, + {"Apr", 4, ~U[2026-04-13 00:00:00Z]}, + {"May", 5, ~U[2026-05-13 00:00:00Z]}, + {"Jun", 6, ~U[2026-06-13 00:00:00Z]}, + {"Jul", 7, ~U[2026-07-13 00:00:00Z]}, + {"Aug", 8, ~U[2026-08-13 00:00:00Z]}, + {"Sep", 9, ~U[2026-09-13 00:00:00Z]}, + {"Oct", 10, ~U[2026-10-13 00:00:00Z]}, + {"Nov", 11, ~U[2026-11-13 00:00:00Z]}, + {"Dec", 12, ~U[2026-12-13 00:00:00Z]} + ] + + for {abbr, _num, expected} <- months do + html = """ +
+ 1000.0 92 25.0 20.0 180 5 ++ """ + + assert [sounding] = UwyoSoundingClient.parse_sounding_html(html) + assert sounding.observed_at == expected + end + end + + test "lines shorter than the 28-char min width are skipped without raising" do + html = """ +
+ too short + 1000.0 92 25.0 20.0 180 5 ++ """ + + assert [sounding] = UwyoSoundingClient.parse_sounding_html(html) + # Only the well-formed row survived. + assert length(sounding.profile) == 1 + assert hd(sounding.profile)["pres"] == 1000.0 + end + end end