prop/test/microwaveprop/space_weather/swpc_client_test.exs
Graham McIntire 0a9058bfa0
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.
2026-04-23 13:56:29 -05:00

208 lines
6.9 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.SpaceWeather.SwpcClientTest do
use ExUnit.Case, async: true
alias Microwaveprop.SpaceWeather.SwpcClient
describe "parse_kp/1" do
test "decodes the SWPC planetary_k_index_1m fixture" do
body = File.read!("test/fixtures/swpc/kp_sample.json")
assert {:ok, rows} = SwpcClient.parse_kp(body)
assert length(rows) == 10
first = hd(rows)
assert first.valid_time == ~U[2026-04-15 13:49:00Z]
assert first.kp_index == 0
assert_in_delta first.estimated_kp, 0.33, 0.001
end
test "returns {:error, _} for malformed bodies" do
assert {:error, _} = SwpcClient.parse_kp("not json")
assert {:error, _} = SwpcClient.parse_kp(~s({"not": "an array"}))
end
end
describe "fetch_xrays/0 upstream truncation" do
# SWPC occasionally returns a truncated JSON body (TCP reset mid-stream).
# Req's auto-decoder raises a Jason.DecodeError carrying the entire
# raw body in :data — historically that dumped hundreds of KB into
# our Logger. The error should be returned, but formatted short.
test "returns {:error, reason} without dumping the full truncated body" do
big_body = "[" <> String.duplicate(~s({"time_tag":"2026-04-20T21:35:00Z","energy":"0.1-0.8nm","flux":1.0},), 20_000)
# Truncate — no closing bracket.
truncated = big_body
Req.Test.stub(SwpcClient, fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, truncated)
end)
assert {:error, reason} = SwpcClient.fetch_xrays()
assert is_binary(reason)
# Upper-bound the log-noise blast radius.
assert byte_size(reason) < 500
# Still descriptive.
assert reason =~ ~r/(decode|JSON|truncated|Jason)/i
end
end
describe "parse_f107/1" do
test "decodes the SWPC f107_cm_flux fixture" do
body = File.read!("test/fixtures/swpc/f107_sample.json")
assert {:ok, rows} = SwpcClient.parse_f107(body)
assert length(rows) == 10
first = hd(rows)
assert first.valid_time == ~U[2026-04-15 17:00:00Z]
assert first.frequency_mhz == 2800
assert first.flux_sfu == 112.0
assert first.reporting_schedule == "Morning"
end
test "keeps nullable historical stats as nil when absent" do
body = File.read!("test/fixtures/swpc/f107_sample.json")
{:ok, [first | _]} = SwpcClient.parse_f107(body)
assert first.ninety_day_mean == nil
end
end
describe "parse_xrays/1" do
test "decodes the GOES xrays-1-day fixture, keeping the 0.1-0.8nm band only" do
body = File.read!("test/fixtures/swpc/xrays_sample.json")
assert {:ok, rows} = SwpcClient.parse_xrays(body)
# Fixture has 12 records (6 timestamps × 2 bands). We keep the
# long-wavelength band (0.1-0.8nm) which is the standard SWF /
# D-layer absorption proxy.
assert length(rows) == 6
assert Enum.all?(rows, fn r -> r.energy_band == "0.1-0.8nm" end)
first = hd(rows)
assert first.valid_time == ~U[2026-04-14 19:49:00Z]
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