- 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.
208 lines
6.9 KiB
Elixir
208 lines
6.9 KiB
Elixir
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
|