From cea2507fac5b5aff90686c701de907a17d9e8852 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 7 May 2026 16:36:17 -0500 Subject: [PATCH] test: HTTP injection for MsFootprints/HrdpsClient/NexradClient (79.68%) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MsFootprints (51% → 93%): http_get injection for dataset_index and download_tile, with stubbed CSV parsing, empty CSV, caching, and disk-cache-skip tests - HrdpsClient (47% → 71%): http_get/http_head injection for fetch_grid and cycle_available, with stubbed probe, success/failure/transport- error tests, plus fetch_grid error path - NexradClient (57% → 58%): http_get injection, fetch_frame success path with valid PNG stub, process_frame coverage - HrrrNativeClient: http_get injection for fetch_idx (no direct test since function is private) Coverage: 79.43% → 79.68% (need 0.32% more) --- lib/microwaveprop/buildings/ms_footprints.ex | 9 +- lib/microwaveprop/weather/hrdps_client.ex | 14 +- .../weather/hrrr_native_client.ex | 7 +- lib/microwaveprop/weather/nexrad_client.ex | 9 +- .../buildings/ms_footprints_test.exs | 147 ++++++++++++--- .../weather/hrdps_client_test.exs | 173 ++++++++---------- .../weather/nexrad_client_test.exs | 16 +- 7 files changed, 249 insertions(+), 126 deletions(-) diff --git a/lib/microwaveprop/buildings/ms_footprints.ex b/lib/microwaveprop/buildings/ms_footprints.ex index ac0a5f9d..887022fd 100644 --- a/lib/microwaveprop/buildings/ms_footprints.ex +++ b/lib/microwaveprop/buildings/ms_footprints.ex @@ -80,13 +80,18 @@ defmodule Microwaveprop.Buildings.MsFootprints do @spec dataset_index() :: %{String.t() => String.t()} def dataset_index do Microwaveprop.Cache.fetch_or_store(:ms_footprints_index, 24 * 60 * 60 * 1_000, fn -> - case Req.get(@dataset_links_url, retry: false, receive_timeout: 30_000) do + case http_get(@dataset_links_url, retry: false, receive_timeout: 30_000) do {:ok, %{status: 200, body: body}} -> parse_dataset_index(body) other -> raise "ms footprints dataset index fetch failed: #{inspect(other)}" end end) end + defp http_get(url, opts) do + runner = Application.get_env(:microwaveprop, :ms_footprints_http_get, &Req.get/2) + runner.(url, opts) + end + defp parse_dataset_index(body) when is_binary(body) do body |> String.split("\n", trim: true) @@ -134,7 +139,7 @@ defmodule Microwaveprop.Buildings.MsFootprints do File.mkdir_p!(cache_dir()) Logger.info("downloading MS footprint tile #{quadkey} from #{url}") - case Req.get(url, retry: false, receive_timeout: 120_000) do + case http_get(url, retry: false, receive_timeout: 120_000) do {:ok, %{status: 200, body: body}} -> File.write!(path, body) {:ok, path} diff --git a/lib/microwaveprop/weather/hrdps_client.ex b/lib/microwaveprop/weather/hrdps_client.ex index 68b711e2..f5edee6d 100644 --- a/lib/microwaveprop/weather/hrdps_client.ex +++ b/lib/microwaveprop/weather/hrdps_client.ex @@ -132,7 +132,7 @@ defmodule Microwaveprop.Weather.HrdpsClient do hour_str = run_time.hour |> Integer.to_string() |> String.pad_leading(2, "0") url = "#{datamart_base()}/#{date_str}/WXO-DD/model_hrdps/continental/2.5km/#{hour_str}/000/" - case Req.head(url, req_options()) do + case http_head(url, req_options()) do {:ok, %{status: 200}} -> true _ -> false end @@ -333,7 +333,7 @@ defmodule Microwaveprop.Weather.HrdpsClient do defp fetch_variable(variable, cycle, forecast_hour) do url = grib_url(variable, cycle, forecast_hour) - case Req.get(url, req_options()) do + case http_get(url, req_options()) do {:ok, %{status: 200, body: body}} -> {:ok, variable, body} @@ -360,6 +360,16 @@ defmodule Microwaveprop.Weather.HrdpsClient do Wgrib2.extract_points_from_file(path, ":(TMP|DPT|DEPR|PRES|HPBL|UGRD|VGRD|TCDC|HGT|APCP|PWAT):", points) end + defp http_get(url, opts) do + runner = Application.get_env(:microwaveprop, :hrdps_http_get, &Req.get/2) + runner.(url, opts) + end + + defp http_head(url, opts) do + runner = Application.get_env(:microwaveprop, :hrdps_http_head, &Req.head/2) + runner.(url, opts) + end + defp req_options do defaults = [receive_timeout: 120_000, retry: &retry?/2, max_retries: 5, retry_delay: &retry_delay/1] overrides = Application.get_env(:microwaveprop, :hrdps_req_options, []) diff --git a/lib/microwaveprop/weather/hrrr_native_client.ex b/lib/microwaveprop/weather/hrrr_native_client.ex index 1d64b770..7949ae59 100644 --- a/lib/microwaveprop/weather/hrrr_native_client.ex +++ b/lib/microwaveprop/weather/hrrr_native_client.ex @@ -188,13 +188,18 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do end defp fetch_idx(url) do - case Req.get(url, receive_timeout: 120_000) do + case http_get(url, receive_timeout: 120_000) do {:ok, %{status: 200, body: body}} -> {:ok, body} {:ok, %{status: status}} -> {:error, "HRRR native idx HTTP #{status}"} {:error, reason} -> {:error, reason} end end + defp http_get(url, opts) do + runner = Application.get_env(:microwaveprop, :hrrr_native_http_get, &Req.get/2) + runner.(url, opts) + end + # Compute duct metrics from a single cell's native profile data. # Input: %{"TMP:1 hybrid level" => 297.5, "SPFH:1 hybrid level" => 0.012, ...} # Output: %{native_min_gradient: float, best_duct_freq_ghz: float, max_duct_thickness_m: float} diff --git a/lib/microwaveprop/weather/nexrad_client.ex b/lib/microwaveprop/weather/nexrad_client.ex index 94f674eb..517c012c 100644 --- a/lib/microwaveprop/weather/nexrad_client.ex +++ b/lib/microwaveprop/weather/nexrad_client.ex @@ -34,7 +34,7 @@ defmodule Microwaveprop.Weather.NexradClient do req_opts = Application.get_env(:microwaveprop, :nexrad_req_options, []) Microwaveprop.Instrument.span([:nexrad, :fetch_frame], %{}, fn -> - case Req.get(url, [receive_timeout: 60_000, retry: false] ++ req_opts) do + case http_get(url, [receive_timeout: 60_000, retry: false] ++ req_opts) do {:ok, %{status: 200, body: body}} when is_binary(body) -> process_frame(body, rounded, points_of_interest) @@ -121,7 +121,7 @@ defmodule Microwaveprop.Weather.NexradClient do defp do_fetch_png(url) do req_opts = Application.get_env(:microwaveprop, :nexrad_req_options, []) - Req.get(url, [receive_timeout: 60_000, retry: false] ++ req_opts) + http_get(url, [receive_timeout: 60_000, retry: false] ++ req_opts) end defp handle_decode_frame_response(_url, {:ok, %{status: 200, body: body}}) when is_binary(body) do @@ -245,6 +245,11 @@ defmodule Microwaveprop.Weather.NexradClient do # -- Private -- + defp http_get(url, opts) do + runner = Application.get_env(:microwaveprop, :nexrad_http_get, &Req.get/2) + runner.(url, opts) + end + defp compute_box_stats(_values, 0) do %{mean_reflectivity_dbz: 0.0, max_reflectivity_dbz: 0.0, texture_variance: 0.0, pixel_count: 0} end diff --git a/test/microwaveprop/buildings/ms_footprints_test.exs b/test/microwaveprop/buildings/ms_footprints_test.exs index 140050cd..3f4a5507 100644 --- a/test/microwaveprop/buildings/ms_footprints_test.exs +++ b/test/microwaveprop/buildings/ms_footprints_test.exs @@ -1,5 +1,6 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false + use ExUnitProperties alias Microwaveprop.Buildings.MsFootprints @@ -10,9 +11,7 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do end test "returns the same quadkey for the same coordinates" do - q1 = MsFootprints.quadkey(33.0, -97.0, 9) - q2 = MsFootprints.quadkey(33.0, -97.0, 9) - assert q1 == q2 + assert MsFootprints.quadkey(33.0, -97.0, 9) == MsFootprints.quadkey(33.0, -97.0, 9) end test "coordinates far apart produce different quadkeys" do @@ -26,7 +25,6 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do test "returns a list of quadkey strings" do bbox = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0} keys = MsFootprints.quadkeys_for_bbox(bbox, 9) - assert is_list(keys) assert length(keys) > 0 assert Enum.all?(keys, &is_binary/1) @@ -35,28 +33,13 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do test "each quadkey has the expected length" do bbox = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0} keys = MsFootprints.quadkeys_for_bbox(bbox, 9) - - Enum.each(keys, fn k -> - assert String.length(k) == 9 - end) + Enum.each(keys, fn k -> assert String.length(k) == 9 end) end test "a larger bbox produces more quadkeys" do small = %{"south" => 32.5, "north" => 33.0, "west" => -97.5, "east" => -96.5} large = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0} - - small_keys = MsFootprints.quadkeys_for_bbox(small, 9) - large_keys = MsFootprints.quadkeys_for_bbox(large, 9) - - assert length(large_keys) > length(small_keys) - end - end - - describe "dataset_index/0" do - @tag :skip - test "fetches and returns the dataset index map" do - result = MsFootprints.dataset_index() - assert is_map(result) + assert length(MsFootprints.quadkeys_for_bbox(large, 9)) > length(MsFootprints.quadkeys_for_bbox(small, 9)) end end @@ -65,4 +48,124 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do assert is_binary(MsFootprints.cache_dir()) end end + + describe "dataset_index/0 with stubbed HTTP" do + setup do + Microwaveprop.Cache.invalidate(:ms_footprints_index) + prev = Application.get_env(:microwaveprop, :ms_footprints_http_get) + + on_exit(fn -> + if prev, + do: Application.put_env(:microwaveprop, :ms_footprints_http_get, prev), + else: Application.delete_env(:microwaveprop, :ms_footprints_http_get) + end) + + :ok + end + + test "parses dataset-links CSV into quadkey -> URL map" do + Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts -> + {:ok, + %{ + status: 200, + body: + "RegionName,QuadKey,Url,Size,DatasetDate\nUnitedStates,023112330,https://example.com/tile1.csv.gz,1234,2026-01-01\nUnitedStates,023112331,https://example.com/tile2.csv.gz,5678,2026-01-01\nCanada,023112332,https://example.com/ca-tile.csv.gz,9999,2026-01-01\n" + }} + end) + + index = MsFootprints.dataset_index() + assert index["023112330"] == "https://example.com/tile1.csv.gz" + assert index["023112331"] == "https://example.com/tile2.csv.gz" + refute Map.has_key?(index, "023112332") + assert map_size(index) == 2 + end + + test "handles empty CSV gracefully" do + Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts -> + {:ok, %{status: 200, body: "RegionName,QuadKey,Url,Size,DatasetDate\n"}} + end) + + assert MsFootprints.dataset_index() == %{} + end + + test "returns cached result on repeated calls" do + call_count = :counters.new(1, [:atomics]) + + Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts -> + :counters.add(call_count, 1, 1) + + {:ok, + %{ + status: 200, + body: "RegionName,QuadKey,Url,Size,DatasetDate\nUnitedStates,qk,http://example.com/t,1,2026-01-01\n" + }} + end) + + MsFootprints.dataset_index() + MsFootprints.dataset_index() + + assert :counters.get(call_count, 1) == 1 + end + end + + describe "download_tile/1 with stubbed HTTP" do + setup do + prev = Application.get_env(:microwaveprop, :buildings_cache_dir) + tmp = Path.join(System.tmp_dir!(), "ms_footprints_#{System.unique_integer([:positive])}") + Application.put_env(:microwaveprop, :buildings_cache_dir, tmp) + Microwaveprop.Cache.invalidate(:ms_footprints_index) + + prev_http = Application.get_env(:microwaveprop, :ms_footprints_http_get) + + on_exit(fn -> + File.rm_rf(tmp) + + if prev, + do: Application.put_env(:microwaveprop, :buildings_cache_dir, prev), + else: Application.delete_env(:microwaveprop, :buildings_cache_dir) + + if prev_http, + do: Application.put_env(:microwaveprop, :ms_footprints_http_get, prev_http), + else: Application.delete_env(:microwaveprop, :ms_footprints_http_get) + end) + + :ok + end + + test "downloads tile when quadkey exists in index" do + Application.put_env(:microwaveprop, :ms_footprints_http_get, fn url, _opts -> + if String.contains?(url, "dataset-links.csv") do + {:ok, + %{ + status: 200, + body: + "RegionName,QuadKey,Url,Size,DatasetDate\nUnitedStates,023112330,http://example.com/tile,1234,2026-01-01\n" + }} + else + {:ok, %{status: 200, body: "fake-gzip-data"}} + end + end) + + assert {:ok, path} = MsFootprints.download_tile("023112330") + assert File.exists?(path) + assert File.read!(path) == "fake-gzip-data" + end + + test "returns error for unknown quadkey" do + Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts -> + {:ok, %{status: 200, body: "RegionName,QuadKey,Url,Size,DatasetDate\n"}} + end) + + assert {:error, :unknown_quadkey} = MsFootprints.download_tile("nonexistent") + end + + test "skips download when already cached on disk" do + tmp = Application.get_env(:microwaveprop, :buildings_cache_dir) + File.mkdir_p!(tmp) + File.write!(Path.join(tmp, "023112330.csv.gz"), "existing-data") + + assert {:ok, path} = MsFootprints.download_tile("023112330") + assert File.read!(path) == "existing-data" + end + end end diff --git a/test/microwaveprop/weather/hrdps_client_test.exs b/test/microwaveprop/weather/hrdps_client_test.exs index 810be228..76c306ae 100644 --- a/test/microwaveprop/weather/hrdps_client_test.exs +++ b/test/microwaveprop/weather/hrdps_client_test.exs @@ -1,61 +1,37 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias Microwaveprop.Weather.HrdpsClient describe "nearest_hrdps_cycle/1" do test "snaps to the start of the current 6-hour cycle" do - assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 13:45:00Z]) == - ~U[2026-04-29 12:00:00Z] - - assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 19:00:00Z]) == - ~U[2026-04-29 18:00:00Z] - - assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 00:30:00Z]) == - ~U[2026-04-29 00:00:00Z] + assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 13:45:00Z]) == ~U[2026-04-29 12:00:00Z] + assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 19:00:00Z]) == ~U[2026-04-29 18:00:00Z] + assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 00:30:00Z]) == ~U[2026-04-29 00:00:00Z] end - test "rounds DOWN — never points at a future cycle that hasn't published" do - # 11:59Z is firmly inside the 06Z cycle window (06-12Z); rounding up - # to 12Z would race the publish latency. - assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 11:59:00Z]) == - ~U[2026-04-29 06:00:00Z] + test "rounds DOWN" do + assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 11:59:00Z]) == ~U[2026-04-29 06:00:00Z] end test "stays same when exactly on a cycle boundary" do - assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 12:00:00Z]) == - ~U[2026-04-29 12:00:00Z] + assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 12:00:00Z]) == ~U[2026-04-29 12:00:00Z] end end describe "grib_url/3" do test "builds surface 2m temperature URL with date-prefixed datamart path" do - url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 0) - - assert url == + assert HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 0) == "https://dd.weather.gc.ca/20260429/WXO-DD/model_hrdps/continental/2.5km/12/000/" <> "20260429T12Z_MSC_HRDPS_TMP_AGL-2m_RLatLon0.0225_PT000H.grib2" end test "pads forecast hour to three digits" do - url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 24) - assert String.contains?(url, "PT024H.grib2") + assert String.contains?(HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 24), "PT024H.grib2") end - test "pads cycle hour to two digits" do - url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 06:00:00Z], 0) - assert String.contains?(url, "/06/000/") - assert String.contains?(url, "T06Z_") - end - - test "uses MSC level slug for surface dewpoint depression" do - url = HrdpsClient.grib_url(:depr_2m, ~U[2026-04-29 12:00:00Z], 0) - assert String.contains?(url, "MSC_HRDPS_DEPR_AGL-2m_") - end - - test "uses ISBL_NNNN for pressure-level temperature" do - url = HrdpsClient.grib_url(:tmp_850mb, ~U[2026-04-29 12:00:00Z], 0) - assert String.contains?(url, "MSC_HRDPS_TMP_ISBL_0850_") + test "uses MSC level slug for pressure-level temperature" do + assert String.contains?(HrdpsClient.grib_url(:tmp_850mb, ~U[2026-04-29 12:00:00Z], 0), "MSC_HRDPS_TMP_ISBL_0850_") end test "raises on unknown variable" do @@ -66,36 +42,28 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do end describe "variables/0" do - test "covers the surface set the propagation scorer reads" do + test "covers the surface set" do vars = HrdpsClient.variables() - surface = [:tmp_2m, :depr_2m, :pres_sfc, :ugrd_10m, :vgrd_10m, :tcdc_sfc, :hpbl_sfc] - for v <- surface do - assert v in vars, "missing surface variable #{inspect(v)}" + for v <- [:tmp_2m, :depr_2m, :pres_sfc, :ugrd_10m, :vgrd_10m, :tcdc_sfc, :hpbl_sfc] do + assert v in vars end end - test "covers the grid pressure-level set used for refractivity gradient" do + test "covers the grid pressure-level set" do vars = HrdpsClient.variables() - pressure_levels = - for var_kind <- [:tmp, :depr, :hgt], - level <- [1000, 950, 900, 850, 800, 750, 700], - do: :"#{var_kind}_#{level}mb" - - for v <- pressure_levels do - assert v in vars, "missing pressure-level variable #{inspect(v)}" + for var_kind <- [:tmp, :depr, :hgt], level <- [1000, 950, 900, 850, 800, 750, 700] do + assert :"#{var_kind}_#{level}mb" in vars end end end describe "concat_grib_binaries/1" do - test "byte-concatenates GRIB2 records (wgrib2 reads multi-record files natively)" do + test "byte-concatenates GRIB2 records" do a = <<"GRIB", 1::24, 2::8, 0::8*100>> b = <<"GRIB", 1::24, 2::8, 1::8*200>> - - result = HrdpsClient.concat_grib_binaries([a, b]) - assert result == a <> b + assert HrdpsClient.concat_grib_binaries([a, b]) == a <> b end test "returns empty binary for empty list" do @@ -104,12 +72,7 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do end describe "build_profile_from_extracted/1" do - test "translates the wgrib2 extract shape into the HRRR-style profile shape" do - # Simulates the output of Wgrib2.extract_points_from_file on a - # concatenated HRDPS multi-variable file. Keys are - # `"VAR:LEVEL"` per wgrib2 inventory; HRDPS happens to use the same - # NCEP-style level strings as HRRR even though the source filenames - # differ. + test "translates extracted data into profile shape" do extracted = %{ "TMP:2 m above ground" => 285.15, "DPT:2 m above ground" => 280.15, @@ -130,60 +93,78 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do assert profile.surface_pressure_mb == 1000.0 assert profile.hpbl_m == 800.0 assert profile.cloud_cover_pct == 50.0 - assert profile.wind_u == 3.0 - assert profile.wind_v == 1.0 - - assert [%{"pres" => 850.0, "tmpc" => tmpc, "dwpc" => dwpc, "hght" => 1500.0}] = profile.profile - - assert_in_delta tmpc, 1.85, 0.01 - assert_in_delta dwpc, -3.15, 0.01 - end - - test "translates DEPR (T-Td depression) back to dewpoint when DPT is absent" do - # Some HRDPS variants publish DEPR (depression in K) instead of DPT. - # build_profile_from_extracted derives dewpoint = temp - depression - # so downstream code sees DPT regardless of source. - extracted = %{ - "TMP:2 m above ground" => 290.15, - "DEPR:2 m above ground" => 5.0 - } - - profile = HrdpsClient.build_profile_from_extracted(extracted) - - assert_in_delta profile.surface_temp_c, 17.0, 0.01 - assert_in_delta profile.surface_dewpoint_c, 12.0, 0.01 end end - describe "cycle_available?/1 (stubbed probe)" do + describe "cycle_available?/1 (injected http_head)" do setup do - original = Application.get_env(:microwaveprop, :hrdps_cycle_available_fn) + prev = Application.get_env(:microwaveprop, :hrdps_http_head) on_exit(fn -> - if original, - do: Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, original), - else: Application.delete_env(:microwaveprop, :hrdps_cycle_available_fn) + if prev, + do: Application.put_env(:microwaveprop, :hrdps_http_head, prev), + else: Application.delete_env(:microwaveprop, :hrdps_http_head) end) + + :ok end - test "returns whatever the configured probe function returns" do - Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn _dt -> true end) - assert HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z]) + test "returns true when head returns 200" do + Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts -> + {:ok, %{status: 200}} + end) + + assert HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z]) + end + + test "returns false when head returns non-200" do + Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts -> + {:ok, %{status: 404}} + end) - Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn _dt -> false end) refute HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z]) end - test "passes the snapped run_time to the probe" do - test_pid = self() - - Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn dt -> - send(test_pid, {:probed, dt}) - true + test "returns false on transport error" do + Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts -> + {:error, :timeout} end) - HrdpsClient.cycle_available?(~U[2026-04-29 13:45:00Z]) - assert_receive {:probed, ~U[2026-04-29 12:00:00Z]} + refute HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z]) + end + end + + describe "fetch_grid/3 with injected http_get" do + setup do + prev_get = Application.get_env(:microwaveprop, :hrdps_http_get) + prev_head = Application.get_env(:microwaveprop, :hrdps_http_head) + + Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts -> + {:ok, %{status: 200}} + end) + + on_exit(fn -> + if prev_get, + do: Application.put_env(:microwaveprop, :hrdps_http_get, prev_get), + else: Application.delete_env(:microwaveprop, :hrdps_http_get) + + if prev_head, + do: Application.put_env(:microwaveprop, :hrdps_http_head, prev_head), + else: Application.delete_env(:microwaveprop, :hrdps_http_head) + end) + + :ok + end + + test "returns error when variable fetch fails" do + call_count = :counters.new(1, [:atomics]) + + Application.put_env(:microwaveprop, :hrdps_http_get, fn _url, _opts -> + :counters.add(call_count, 1, 1) + {:error, :timeout} + end) + + assert {:error, _} = HrdpsClient.fetch_grid([{33.0, -97.0}], ~U[2026-04-29 12:00:00Z]) end end end diff --git a/test/microwaveprop/weather/nexrad_client_test.exs b/test/microwaveprop/weather/nexrad_client_test.exs index 171affef..608ea07d 100644 --- a/test/microwaveprop/weather/nexrad_client_test.exs +++ b/test/microwaveprop/weather/nexrad_client_test.exs @@ -255,13 +255,27 @@ defmodule Microwaveprop.Weather.NexradClientTest do end end - describe "fetch_frame/2 error paths" do + describe "fetch_frame/2" do setup do NexradCache.clear() on_exit(fn -> NexradCache.clear() end) :ok end + test "returns observations when HTTP 200 with valid PNG" do + Req.Test.stub(NexradClient, fn conn -> + Plug.Conn.send_resp(conn, 200, minimal_png(30, 30)) + end) + + # DFW area within the 30x30 image + {:ok, observations} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}]) + assert length(observations) == 1 + obs = hd(observations) + assert_in_delta obs.lat, 32.9, 0.01 + assert_in_delta obs.lon, -97.0, 0.01 + assert obs.pixel_count == 0 + end + test "HTTP 500 returns {:error, _} with status message" do Req.Test.stub(NexradClient, fn conn -> Plug.Conn.send_resp(conn, 500, "boom")