defmodule Microwaveprop.Weather.NexradClientTest do use ExUnit.Case, async: false use ExUnitProperties alias Microwaveprop.Weather.NexradCache alias Microwaveprop.Weather.NexradClient describe "round_to_5min/1" do test "rounds down to nearest 5-minute boundary" do dt = ~U[2022-08-20 14:07:32Z] assert NexradClient.round_to_5min(dt) == ~U[2022-08-20 14:05:00Z] end test "already on a 5-minute boundary stays unchanged" do dt = ~U[2022-08-20 14:10:00Z] assert NexradClient.round_to_5min(dt) == ~U[2022-08-20 14:10:00Z] end test "59 minutes rounds down to 55" do dt = ~U[2022-08-20 14:59:59Z] assert NexradClient.round_to_5min(dt) == ~U[2022-08-20 14:55:00Z] end end describe "frame_url/1" do test "builds correct IEM n0q URL from datetime" do dt = ~U[2022-08-20 14:05:00Z] url = NexradClient.frame_url(dt) assert url == "https://mesonet.agron.iastate.edu/archive/data/2022/08/20/GIS/uscomp/n0q_202208201405.png" end test "pads single-digit month and day" do dt = ~U[2022-01-05 03:00:00Z] url = NexradClient.frame_url(dt) assert url == "https://mesonet.agron.iastate.edu/archive/data/2022/01/05/GIS/uscomp/n0q_202201050300.png" end end describe "latlon_to_pixel/2" do test "converts coordinates to pixel positions" do # Top-left corner: lat=50, lon=-126 {x, y} = NexradClient.latlon_to_pixel(50.0, -126.0) assert x == 0 assert y == 0 end test "bottom-right corner" do # lon = -65 -> x = (-65 - -126) / 0.005 = 61 / 0.005 = 12200 # lat = 23 -> y = (50 - 23) / 0.005 = 27 / 0.005 = 5400 {x, y} = NexradClient.latlon_to_pixel(23.0, -65.0) assert x == 12_200 assert y == 5400 end test "DFW area" do # DFW: lat ~32.9, lon ~-97.0 {x, y} = NexradClient.latlon_to_pixel(32.9, -97.0) # x = (-97 - -126) / 0.005 = 29 / 0.005 = 5800 assert x == 5800 # y = (50 - 32.9) / 0.005 = 17.1 / 0.005 = 3420 assert y == 3420 end end describe "extract_box/4" do test "extracts stats from a box of pixel values" do # Create a simple 100x100 image (all zeros except a small region) width = 100 height = 100 pixels = :binary.copy(<<0>>, width * height) # Put some non-zero values around pixel (50, 50) - a 5x5 block pixels = Enum.reduce(48..52, pixels, fn y, acc -> Enum.reduce(48..52, acc, fn x, inner_acc -> offset = y * width + x <> = inner_acc <> end) end) result = NexradClient.extract_box(pixels, width, 50, 50, 5) assert result.pixel_count == 25 assert result.max_reflectivity_dbz > 0 assert result.mean_reflectivity_dbz > 0 assert result.texture_variance == 0.0 end test "returns zeros for an empty box" do width = 100 height = 100 pixels = :binary.copy(<<0>>, width * height) result = NexradClient.extract_box(pixels, width, 50, 50, 5) assert result.pixel_count == 0 assert result.mean_reflectivity_dbz == 0.0 assert result.max_reflectivity_dbz == 0.0 assert result.texture_variance == 0.0 end test "computes texture variance for mixed values" do width = 100 height = 100 pixels = :binary.copy(<<0>>, width * height) # Place alternating values: half 100, half 200 pixels = Enum.reduce(48..52, pixels, fn y, acc -> Enum.reduce(48..52, acc, fn x, inner_acc -> offset = y * width + x value = if rem(x + y, 2) == 0, do: 100, else: 200 <> = inner_acc <> end) end) result = NexradClient.extract_box(pixels, width, 50, 50, 5) assert result.pixel_count == 25 # Variance should be non-zero since we have mixed values assert result.texture_variance > 0.0 end test "handles box at image boundary by clamping" do width = 100 height = 100 pixels = :binary.copy(<<50>>, width * height) # Corner case: near edge result = NexradClient.extract_box(pixels, width, 2, 2, 25) # Should not crash; pixel_count depends on how many pixels are in bounds assert result.pixel_count > 0 end end describe "fetch_decoded_frame/1 caching" do # Minimal 20×20 indexed-color PNG identical to the NexradWorkerTest # fixture. Used here just to give the decoder something valid to # chew on; caching semantics don't care about the contents. defp minimal_png(width \\ 20, height \\ 20) do signature = <<137, 80, 78, 71, 13, 10, 26, 10>> ihdr_data = <> ihdr_chunk = <> raw = for _ <- 1..height, into: <<>>, do: <<0, 0::size(width)-unit(8)>> compressed = :zlib.compress(raw) idat_chunk = <> iend_chunk = <<0::32, "IEND", 0::32>> signature <> ihdr_chunk <> idat_chunk <> iend_chunk end setup do NexradCache.clear() on_exit(fn -> NexradCache.clear() end) :ok end test "first call fetches+decodes; second call for same 5-min bucket is served from cache" do test_pid = self() Req.Test.stub(NexradClient, fn conn -> send(test_pid, :http_fetched) Plug.Conn.send_resp(conn, 200, minimal_png()) end) ts = ~U[2022-08-20 14:07:00Z] {:ok, pixels1, width1} = NexradClient.fetch_decoded_frame(ts) {:ok, pixels2, width2} = NexradClient.fetch_decoded_frame(ts) # The cache is keyed on the 5-min rounded timestamp, so a # second call within the same bucket reuses the decoded pixels. assert pixels1 == pixels2 assert width1 == width2 assert_received :http_fetched refute_received :http_fetched end test "different 5-min buckets hit the network separately" do test_pid = self() Req.Test.stub(NexradClient, fn conn -> send(test_pid, :http_fetched) Plug.Conn.send_resp(conn, 200, minimal_png()) end) {:ok, _, _} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:05:00Z]) {:ok, _, _} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:10:00Z]) assert_received :http_fetched assert_received :http_fetched end end describe "pixel_to_dbz/1" do test "value 0 maps to 0 (no echo)" do assert NexradClient.pixel_to_dbz(0) == 0.0 end test "value 255 maps to approximately 95 dBZ" do dbz = NexradClient.pixel_to_dbz(255) assert_in_delta dbz, 95.0, 1.0 end test "mid-range value produces reasonable dBZ" do dbz = NexradClient.pixel_to_dbz(128) assert dbz > 0.0 assert dbz < 95.0 end property "is monotonic non-decreasing across [1, 255]" do check all( a <- integer(1..254), delta <- integer(0..254) ) do b = min(a + delta, 255) assert NexradClient.pixel_to_dbz(a) <= NexradClient.pixel_to_dbz(b) end end property "output is in [-30.0, 95.0] for any byte value [1, 255]" do check all(value <- integer(1..255)) do dbz = NexradClient.pixel_to_dbz(value) assert dbz >= -30.0 assert dbz <= 95.0 + 1.0e-9 end end end describe "latlon_to_pixel/2 invariants" do property "matching lat/lon corners snap to pixel corners exactly" do # The CONUS grid is a 12201×5401 frame anchored at (50N, -126W) # with 0.005° resolution. Every cell center whose (lat, lon) # is aligned with the grid must round-trip to the integer pixel # coordinates the module's constants imply. check all( x <- integer(0..12_200), y <- integer(0..5400) ) do lat = 50.0 - y * 0.005 lon = -126.0 + x * 0.005 {px, py} = NexradClient.latlon_to_pixel(lat, lon) assert px == x assert py == y end end end describe "fetch_frame/2 error paths" do setup do NexradCache.clear() on_exit(fn -> NexradCache.clear() end) :ok end test "HTTP 500 returns {:error, _} with status message" do Req.Test.stub(NexradClient, fn conn -> Plug.Conn.send_resp(conn, 500, "boom") end) assert {:error, msg} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}]) assert msg =~ "500" end test "HTTP 404 returns {:error, _}" do Req.Test.stub(NexradClient, fn conn -> Plug.Conn.send_resp(conn, 404, "not found") end) assert {:error, msg} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}]) assert msg =~ "404" end test "transport timeout bubbles up as {:error, _}" do Req.Test.stub(NexradClient, fn conn -> Req.Test.transport_error(conn, :timeout) end) assert {:error, reason} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}]) assert reason end end describe "fetch_decoded_frame/1 error paths" do setup do NexradCache.clear() on_exit(fn -> NexradCache.clear() end) :ok end test "HTTP 503 returns {:error, _} and does not populate cache" do Req.Test.stub(NexradClient, fn conn -> Plug.Conn.send_resp(conn, 503, "unavailable") end) ts = ~U[2022-08-20 14:05:00Z] assert {:error, msg} = NexradClient.fetch_decoded_frame(ts) assert msg =~ "503" # Cache must not carry forward a 503 — next call must hit the network # again (the stub is still in place, so we still get the same error). assert {:error, _} = NexradClient.fetch_decoded_frame(ts) end test "malformed PNG body returns {:error, _} from the decoder" do Req.Test.stub(NexradClient, fn conn -> Plug.Conn.send_resp(conn, 200, "not a png") end) assert {:error, reason} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:05:00Z]) # decode_png_to_pixels wraps any exception into this string assert is_binary(reason) assert reason =~ "PNG decode failed" end end describe "frame_url/1 round-trip property" do property "url embeds the date path and a 12-digit timestamp for any rounded 5-min input" do check all( year <- integer(2015..2030), month <- integer(1..12), day <- integer(1..28), hour <- integer(0..23), minute_bucket <- integer(0..11) ) do dt = %DateTime{ year: year, month: month, day: day, hour: hour, minute: minute_bucket * 5, second: 0, microsecond: {0, 0}, std_offset: 0, utc_offset: 0, zone_abbr: "UTC", time_zone: "Etc/UTC" } url = NexradClient.frame_url(dt) date_path = "#{year}/#{String.pad_leading(Integer.to_string(month), 2, "0")}/" <> String.pad_leading(Integer.to_string(day), 2, "0") file_ts = [year, month, day, hour, minute_bucket * 5] |> Enum.map(&Integer.to_string/1) |> Enum.zip([4, 2, 2, 2, 2]) |> Enum.map_join(fn {str, w} -> String.pad_leading(str, w, "0") end) assert String.contains?(url, date_path) assert String.contains?(url, "n0q_#{file_ts}.png") end end end describe "extract_box/5 edge cases" do test "box partially off the left edge returns whatever pixels fall inside" do # 4-row × 10-col frame, every pixel hot. pixels = :binary.copy(<<128>>, 40) # Centre x=0 → x_min=0, x_max=2. Centre y=0 → y_min=0, y_max=2. result = NexradClient.extract_box(pixels, 10, 0, 0, 2) assert result.pixel_count > 0 assert result.max_reflectivity_dbz > 0.0 end test "box entirely off the right edge returns no-data (no pixels)" do pixels = :binary.copy(<<128>>, 40) # width=10, cx=50 → x_min=48, x_max=9 → empty range after clamp. result = NexradClient.extract_box(pixels, 10, 50, 2, 2) assert result.pixel_count == 0 assert result.mean_reflectivity_dbz == 0.0 assert result.max_reflectivity_dbz == 0.0 assert result.texture_variance == 0.0 end test "zero pixels in box yields the no-echo stats tuple" do pixels = :binary.copy(<<0>>, 40) result = NexradClient.extract_box(pixels, 10, 5, 2, 2) # Every pixel is 0 → none enter the filtered dbz_values list. assert result.pixel_count == 0 assert result.mean_reflectivity_dbz == 0.0 end end describe "fetch_decoded_frame/1 cache population" do # Minimal 20×20 indexed-color PNG identical to the earlier fixture. defp tiny_png(width \\ 20, height \\ 20) do signature = <<137, 80, 78, 71, 13, 10, 26, 10>> ihdr_data = <> ihdr_chunk = <> raw = for _ <- 1..height, into: <<>>, do: <<0, 0::size(width)-unit(8)>> compressed = :zlib.compress(raw) idat_chunk = <> iend_chunk = <<0::32, "IEND", 0::32>> signature <> ihdr_chunk <> idat_chunk <> iend_chunk end setup do NexradCache.clear() on_exit(fn -> NexradCache.clear() end) :ok end test "successful fetch populates the cache (second fetch is a :hit)" do Req.Test.stub(NexradClient, fn conn -> Plug.Conn.send_resp(conn, 200, tiny_png()) end) ts = ~U[2022-08-20 14:07:00Z] rounded = NexradClient.round_to_5min(ts) assert NexradCache.fetch(rounded) == :miss {:ok, _pixels, _width} = NexradClient.fetch_decoded_frame(ts) assert {:ok, _pixels2, _width2} = NexradCache.fetch(rounded) end end describe "fetch_decoded_frame/1 empty body" do setup do NexradCache.clear() on_exit(fn -> NexradCache.clear() end) :ok end test "zero-byte 200 response surfaces a PNG decode error" do Req.Test.stub(NexradClient, fn conn -> Plug.Conn.send_resp(conn, 200, "") end) assert {:error, reason} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:05:00Z]) assert is_binary(reason) assert reason =~ "PNG decode failed" end end describe "frame_url/1 year boundaries" do test "Dec 31 23:55 produces a URL anchored in the old year" do dt = ~U[2022-12-31 23:55:00Z] url = NexradClient.frame_url(dt) assert url =~ "2022/12/31" assert url =~ "n0q_202212312355.png" refute url =~ "2023/" end test "Jan 1 00:00 of the following year flips cleanly into the new year" do dt = ~U[2023-01-01 00:00:00Z] url = NexradClient.frame_url(dt) assert url =~ "2023/01/01" assert url =~ "n0q_202301010000.png" refute url =~ "2022/" end test "rounding 23:59 Dec 31 stays in Dec 31 (no rollover via round_to_5min)" do dt = ~U[2022-12-31 23:59:59Z] rounded = NexradClient.round_to_5min(dt) assert rounded.year == 2022 assert rounded.month == 12 assert rounded.day == 31 assert rounded.hour == 23 assert rounded.minute == 55 url = NexradClient.frame_url(rounded) assert url =~ "2022/12/31" end end end