- 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.
183 lines
6.5 KiB
Elixir
183 lines
6.5 KiB
Elixir
defmodule Microwaveprop.Weather.UwyoSoundingClientTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Weather.UwyoSoundingClient
|
|
|
|
@fixture File.read!("test/support/fixtures/uwyo_sounding_yyr.html")
|
|
|
|
describe "sounding_url/2" do
|
|
test "builds the correct URL for a 3-letter station code + datetime" do
|
|
url = UwyoSoundingClient.sounding_url("YYR", ~U[2026-04-13 00:00:00Z])
|
|
|
|
assert url ==
|
|
"https://weather.uwyo.edu/cgi-bin/sounding" <>
|
|
"?region=naconf&TYPE=TEXT%3ALIST" <>
|
|
"&YEAR=2026&MONTH=04&FROM=1300&TO=1300&STNM=YYR"
|
|
end
|
|
|
|
test "pads month and day-hour to two digits" do
|
|
url = UwyoSoundingClient.sounding_url("WSE", ~U[2026-01-05 12:00:00Z])
|
|
|
|
assert url =~ "MONTH=01"
|
|
assert url =~ "FROM=0512"
|
|
assert url =~ "TO=0512"
|
|
assert url =~ "STNM=WSE"
|
|
end
|
|
end
|
|
|
|
describe "parse_sounding_html/1" do
|
|
test "returns one sounding entry for a valid response" do
|
|
assert [sounding] = UwyoSoundingClient.parse_sounding_html(@fixture)
|
|
assert sounding.observed_at == ~U[2026-04-13 00:00:00Z]
|
|
assert is_list(sounding.profile)
|
|
assert length(sounding.profile) > 20
|
|
end
|
|
|
|
test "parses the surface level with all fields populated" do
|
|
[sounding] = UwyoSoundingClient.parse_sounding_html(@fixture)
|
|
surface = hd(sounding.profile)
|
|
|
|
assert surface["pres"] == 1016.0
|
|
assert surface["hght"] == 36.0
|
|
assert surface["tmpc"] == -3.5
|
|
assert surface["dwpc"] == -9.5
|
|
assert surface["drct"] == 145.0
|
|
assert surface["sknt"] == 1.0
|
|
end
|
|
|
|
test "each level has the same key set (pres, hght, tmpc, dwpc, drct, sknt)" do
|
|
[sounding] = UwyoSoundingClient.parse_sounding_html(@fixture)
|
|
|
|
Enum.each(sounding.profile, fn level ->
|
|
assert Map.has_key?(level, "pres")
|
|
assert Map.has_key?(level, "hght")
|
|
assert Map.has_key?(level, "tmpc")
|
|
assert Map.has_key?(level, "dwpc")
|
|
assert Map.has_key?(level, "drct")
|
|
assert Map.has_key?(level, "sknt")
|
|
end)
|
|
end
|
|
|
|
test "levels with missing temp/dewpoint are omitted" do
|
|
# Lines like ' 1000.0 92' (only PRES + HGHT, rest blank) must not
|
|
# appear in the parsed profile because SoundingParams.derive/1 needs temp.
|
|
html = """
|
|
<H2>71119 WSE Edmonton Stony Plain Observations at 00Z 13 Apr 2026</H2>
|
|
<PRE>
|
|
-----------------------------------------------------------------------------
|
|
PRES HGHT TEMP DWPT RELH MIXR DRCT SKNT THTA THTE THTV
|
|
hPa m C C % g/kg deg knot K K K
|
|
-----------------------------------------------------------------------------
|
|
1000.0 92
|
|
920.0 766 2.2 -3.8 65 3.15 85 6 282.0 291.1 282.5
|
|
</PRE>
|
|
"""
|
|
|
|
[sounding] = UwyoSoundingClient.parse_sounding_html(html)
|
|
assert length(sounding.profile) == 1
|
|
assert hd(sounding.profile)["pres"] == 920.0
|
|
end
|
|
|
|
test "returns an empty list when the response contains no PRE block" do
|
|
html = "Sorry, the server is too busy to process your request.\n"
|
|
assert UwyoSoundingClient.parse_sounding_html(html) == []
|
|
end
|
|
|
|
test "returns an empty list when the header is missing" do
|
|
html = "<HTML><PRE> 1000.0 30 25.0 20.0 50 0.0 180 10 0 0 0</PRE></HTML>"
|
|
assert UwyoSoundingClient.parse_sounding_html(html) == []
|
|
end
|
|
end
|
|
|
|
describe "fetch_sounding/2" do
|
|
test "returns parsed sounding on a 200 response" do
|
|
Req.Test.stub(UwyoSoundingClient, fn conn ->
|
|
Req.Test.text(conn, @fixture)
|
|
end)
|
|
|
|
assert {:ok, [sounding]} =
|
|
UwyoSoundingClient.fetch_sounding("YYR", ~U[2026-04-13 00:00:00Z])
|
|
|
|
assert sounding.observed_at == ~U[2026-04-13 00:00:00Z]
|
|
refute sounding.profile == []
|
|
end
|
|
|
|
test "returns an empty list when the server is busy" do
|
|
Req.Test.stub(UwyoSoundingClient, fn conn ->
|
|
Req.Test.text(conn, "Sorry, the server is too busy to process your request.\n")
|
|
end)
|
|
|
|
assert {:ok, []} = UwyoSoundingClient.fetch_sounding("YYR", ~U[2026-04-13 00:00:00Z])
|
|
end
|
|
|
|
test "returns an error on a non-200 response" do
|
|
Req.Test.stub(UwyoSoundingClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 503, "Service Unavailable")
|
|
end)
|
|
|
|
assert {:error, "UWYO sounding HTTP 503"} =
|
|
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 = """
|
|
<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
|