- Add 17 missing @spec annotations (layouts, error_json, error_html, skewt_svg) - Move 12+ nested alias/import/require to module top level - Add phx-change/id attributes to 11 raw HTML <form> tags - Remove 4 unused LiveView assigns (:bounds, :data_provider) - Add 3 missing doctest references (HrrrNativeClient, BulkFetch, Accounts) - Break 2 long lines (path_compute.ex:382) - Strengthen weak test assertions (is_binary→byte_size, is_list→!=[]) - Replace Module.concat with Module.safe_concat (2 occurrences) - Replace length/1 > 0 with list != [] (9 occurrences) - Remove no-op assert true, fix no-assertion tests Remaining: 24 socket.assigns introspection warnings (deliberate test pattern for observable behavior testing), 1 formatter-resistant long line, 3 app-code usage warnings.
524 lines
17 KiB
Elixir
524 lines
17 KiB
Elixir
defmodule Microwaveprop.Weather.NexradClientTest do
|
|
use ExUnit.Case, async: false
|
|
use ExUnitProperties
|
|
|
|
alias Microwaveprop.Weather.NexradCache
|
|
alias Microwaveprop.Weather.NexradClient
|
|
|
|
describe "fetch_rain_cells/4 with injected http_get" do
|
|
setup do
|
|
prev = Application.get_env(:microwaveprop, :nexrad_http_get)
|
|
NexradCache.clear()
|
|
|
|
on_exit(fn ->
|
|
if prev,
|
|
do: Application.put_env(:microwaveprop, :nexrad_http_get, prev),
|
|
else: Application.delete_env(:microwaveprop, :nexrad_http_get)
|
|
|
|
NexradCache.clear()
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
test "propagates an HTTP non-200 status from the IEM archive" do
|
|
Application.put_env(:microwaveprop, :nexrad_http_get, fn _url, _opts ->
|
|
{:ok, %{status: 404, body: ""}}
|
|
end)
|
|
|
|
assert {:error, "NEXRAD n0q HTTP 404"} = NexradClient.fetch_rain_cells(32.9, -97.0, 50.0, 25.0)
|
|
end
|
|
|
|
test "propagates a transport error" do
|
|
Application.put_env(:microwaveprop, :nexrad_http_get, fn _url, _opts ->
|
|
{:error, :timeout}
|
|
end)
|
|
|
|
assert {:error, :timeout} = NexradClient.fetch_rain_cells(32.9, -97.0, 50.0, 25.0)
|
|
end
|
|
end
|
|
|
|
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
|
|
<<pre::binary-size(offset), _::8, post::binary>> = inner_acc
|
|
<<pre::binary, 120::8, post::binary>>
|
|
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
|
|
<<pre::binary-size(offset), _::8, post::binary>> = inner_acc
|
|
<<pre::binary, value::8, post::binary>>
|
|
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 20x20 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 = <<width::32, height::32, 8, 3, 0, 0, 0>>
|
|
ihdr_chunk = <<byte_size(ihdr_data)::32, "IHDR", ihdr_data::binary, 0::32>>
|
|
|
|
raw = for _ <- 1..height, into: <<>>, do: <<0, 0::size(width)-unit(8)>>
|
|
compressed = :zlib.compress(raw)
|
|
idat_chunk = <<byte_size(compressed)::32, "IDAT", compressed::binary, 0::32>>
|
|
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 12201x5401 frame anchored at (50N, -126W)
|
|
# with 0.005deg 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" 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")
|
|
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}])
|
|
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 byte_size(reason) > 0
|
|
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 x 10-col frame, every pixel hot.
|
|
pixels = :binary.copy(<<128>>, 40)
|
|
# Centre x=0 rarr x_min=0, x_max=2. Centre y=0 rarr 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 rarr x_min=48, x_max=9 rarr 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 rarr 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 20x20 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 = <<width::32, height::32, 8, 3, 0, 0, 0>>
|
|
ihdr_chunk = <<byte_size(ihdr_data)::32, "IHDR", ihdr_data::binary, 0::32>>
|
|
|
|
raw = for _ <- 1..height, into: <<>>, do: <<0, 0::size(width)-unit(8)>>
|
|
compressed = :zlib.compress(raw)
|
|
idat_chunk = <<byte_size(compressed)::32, "IDAT", compressed::binary, 0::32>>
|
|
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 byte_size(reason) > 0
|
|
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
|