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 = """

71119 WSE Edmonton Stony Plain Observations at 00Z 13 Apr 2026

      -----------------------------------------------------------------------------
         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
      
""" [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 = "
 1000.0 30 25.0 20.0 50 0.0 180 10 0 0 0
" 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 = """

71816 YYR Goose Bay Observations at 00Z 13 #{bad_month} 2026

         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 = """

71816 YYR Goose Bay Observations at 00Z 13 #{abbr} 2026

         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 = """

71816 YYR Goose Bay Observations at 00Z 13 Apr 2026

       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