test: broaden coverage across parsers, schemas, and pure helpers
- SwpcClient: unparseable time_tag rows drop silently, integer / string
numerics both cast, already-decoded list bodies (Req auto-decode) are
accepted, non-JSON-array bodies return {:error, :not_a_list}, X-ray
rows whose energy isn't the long wavelength are filtered, and the
misspelled-upstream electron_contaminaton key flags
electron_contaminated.
- UwyoSoundingClient: every month abbreviation routes to its correct
number, bogus month tokens fall through to an empty result, and
short-of-fixed-width lines are skipped without raising.
- Grid.wgrib2_grid_spec/0: lon/lat_start anchor to the SW corner,
steps match Grid.step/0, cell counts span CONUS inclusive, and the
spec's cell product equals length(Grid.conus_points/0).
- PollWorker: empty-link list is a no-op, and poll_fn receives host /
community / radio_type straight from the link row.
- Contact submission_changeset: antenna heights are bounded to
[0, 1000] ft, whitespace-only mode normalises without firing an
invalid-mode error, plus a pair of StreamData properties — every
sanctioned band validates and every out-of-list band string is
rejected.
Suite: 2,380 tests + 148 properties (was 2,370 + 146); coverage
70.38% → 71.08% total; credo strict clean.
This commit is contained in:
parent
75ec3f8d1e
commit
0a9058bfa0
5 changed files with 295 additions and 0 deletions
|
|
@ -65,5 +65,36 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do
|
||||||
|
|
||||||
assert length(Repo.all(Sample)) == 1
|
assert length(Repo.all(Sample)) == 1
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -53,4 +53,32 @@ defmodule Microwaveprop.Propagation.GridTest do
|
||||||
assert bounds.lon_max == -66.0
|
assert bounds.lon_max == -66.0
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,61 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
|
||||||
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
refute Ecto.Changeset.get_change(changeset, :user_submitted)
|
refute Ecto.Changeset.get_change(changeset, :user_submitted)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "notes field" do
|
describe "notes field" do
|
||||||
|
|
|
||||||
|
|
@ -84,5 +84,125 @@ defmodule Microwaveprop.SpaceWeather.SwpcClientTest do
|
||||||
assert first.satellite == 18
|
assert first.satellite == 18
|
||||||
assert_in_delta first.flux_wm2, 3.523e-07, 1.0e-09
|
assert_in_delta first.flux_wm2, 3.523e-07, 1.0e-09
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -119,4 +119,65 @@ defmodule Microwaveprop.Weather.UwyoSoundingClientTest do
|
||||||
UwyoSoundingClient.fetch_sounding("YYR", ~U[2026-04-13 00:00:00Z])
|
UwyoSoundingClient.fetch_sounding("YYR", ~U[2026-04-13 00:00:00Z])
|
||||||
end
|
end
|
||||||
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 = """
|
||||||
|
<H2>71816 YYR Goose Bay Observations at 00Z 13 #{bad_month} 2026</H2>
|
||||||
|
<PRE>
|
||||||
|
1000.0 92 25.0 20.0
|
||||||
|
</PRE>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 = """
|
||||||
|
<H2>71816 YYR Goose Bay Observations at 00Z 13 #{abbr} 2026</H2>
|
||||||
|
<PRE>
|
||||||
|
1000.0 92 25.0 20.0 180 5
|
||||||
|
</PRE>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 = """
|
||||||
|
<H2>71816 YYR Goose Bay Observations at 00Z 13 Apr 2026</H2>
|
||||||
|
<PRE>
|
||||||
|
too short
|
||||||
|
1000.0 92 25.0 20.0 180 5
|
||||||
|
</PRE>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue